React - How to format phone number as user types

It's all about formatting. Any key that prints a character
should cause a rewrite of the input field.
This way the user only sees valid formatted field, no matter what he does.

The regex is simple ^\D*(\d{0,3})\D*(\d{0,3})\D*(\d{0,4})

function getFormattedPhoneNum( input ) {
  let output = "(";
  input.replace( /^\D*(\d{0,3})\D*(\d{0,3})\D*(\d{0,4})/, function( match, g1, g2, g3 )
      {
        if ( g1.length ) {
          output += g1;
          if ( g1.length == 3 ) {
              output += ")";
              if ( g2.length ) {
                  output += " " + g2; 
                  if ( g2.length == 3 ) {
                      output += " - ";
                      if ( g3.length ) {
                          output += g3;
                      }
                  }
              }
           }
        }
      }       
    );        
  return output;
 }       

console.log( getFormattedPhoneNum("") );
console.log( getFormattedPhoneNum("2") );
console.log( getFormattedPhoneNum("asdf20as3d") );
console.log( getFormattedPhoneNum("203") );
console.log( getFormattedPhoneNum("203-44") );
console.log( getFormattedPhoneNum("444sg52asdf22fd44gs") );
console.log( getFormattedPhoneNum("444sg526sdf22fd44gs") );
console.log( getFormattedPhoneNum("444sg526sdf2244gs") );
console.log( getFormattedPhoneNum(" ra098 848 73653k-atui ") );

You could even get fancier and show underscores where a character
should be at any given time.
Like
(___) ___ - ____
(20_) ___ - ____
(123) 456 - ____

etc... (let me know if you want this)


You can normalize the input like so

  • the value is up-to-date in relation to event.target.value
  • previousValue is what has already been validated and set to state

This is structured in a way to prevent invalid characters from updating the input and also limits the input to 10 numbers.

Click the Run code snippet button below for a working example.


const normalizeInput = (value, previousValue) => {
  // return nothing if no value
  if (!value) return value; 

  // only allows 0-9 inputs
  const currentValue = value.replace(/[^\d]/g, '');
  const cvLength = currentValue.length; 

  if (!previousValue || value.length > previousValue.length) {

    // returns: "x", "xx", "xxx"
    if (cvLength < 4) return currentValue; 

    // returns: "(xxx)", "(xxx) x", "(xxx) xx", "(xxx) xxx",
    if (cvLength < 7) return `(${currentValue.slice(0, 3)}) ${currentValue.slice(3)}`; 

    // returns: "(xxx) xxx-", (xxx) xxx-x", "(xxx) xxx-xx", "(xxx) xxx-xxx", "(xxx) xxx-xxxx"
    return `(${currentValue.slice(0, 3)}) ${currentValue.slice(3, 6)}-${currentValue.slice(6, 10)}`; 
  }
};

const normalizeInput = (value, previousValue) => {
  if (!value) return value;
  const currentValue = value.replace(/[^\d]/g, '');
  const cvLength = currentValue.length;
  
  if (!previousValue || value.length > previousValue.length) {
    if (cvLength < 4) return currentValue;
    if (cvLength < 7) return `(${currentValue.slice(0, 3)}) ${currentValue.slice(3)}`;
    return `(${currentValue.slice(0, 3)}) ${currentValue.slice(3, 6)}-${currentValue.slice(6, 10)}`;
  }
};

const validateInput = value => {
  let error = ""
  
  if (!value) error = "Required!"
  else if (value.length !== 14) error = "Invalid phone format. ex: (555) 555-5555";
  
  return error;
};
    
class Form extends React.Component {
  constructor() {
    super();
    
    this.state = { phone: "", error: "" };
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.handleReset = this.handleReset.bind(this);
  }
  
  handleChange({ target: { value } }) {   
    this.setState(prevState=> ({ phone: normalizeInput(value, prevState.phone) }));
  };
  
  handleSubmit(e) {
    e.preventDefault();
    const error = validateInput(this.state.phone);
    
    this.setState({ error }, () => {
       if(!error) {
         setTimeout(() => {
           alert(JSON.stringify(this.state, null, 4));
         }, 300)
       }
    });
  }
  
  handleReset() {
     this.setState({ phone: "", error: "" });
  };
  
  render() {
    return(
      <form className="form" onSubmit={this.handleSubmit}>
        <div className="input-container">
          <p className="label">Phone:</p>
          <input
            className="input"
            type="text"
            name="phone"
            placeholder="(xxx) xxx-xxxx"
            value={this.state.phone}
            onChange={this.handleChange}
          />
          {this.state.error && <p className="error">{this.state.error}</p>}
        </div>
        <div className="btn-container">
          <button 
             className="btn danger"
             type="button"
             onClick={this.handleReset}
           >
            Reset
          </button>
          <button className="btn primary" type="submit">Submit</button>
        </div>
      </form>
    );
  }
}

ReactDOM.render(
  <Form />,
  document.getElementById('root')
);
html {
  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
  font-size: 16px;
  font-weight: 400;
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
  background: #fff;
  color: #666;
}

.btn {
  color: #fff;
  border: 1px solid transparent;
  margin: 0 10px;
  cursor: pointer;
  text-align: center;
  box-sizing: border-box;
  padding: 0 30px;
  vertical-align: middle;
  font-size: .875rem;
  line-height: 38px;
  text-align: center;
  text-decoration: none;
  text-transform: uppercase;
  transition: .1s ease-in-out;
  transition-property: color,background-color,border-color;
}

.btn:focus {
  outline: 0;
}

.btn-container {
  text-align: center;
  margin-top: 10px;
}

.form {
  width: 550px;
  margin: 0 auto;
}

.danger {
  background-color: #f0506e;
  color: #fff;
  border: 1px solid transparent;
}
 
.danger:hover {
  background-color: #ee395b;
  color: #fff;
}

.error {
  margin: 0;
  margin-top: -20px;
  padding-left: 26%;
  color: red;
  text-align: left;
}

.input {
  display: inline-block;
  height: 40px;
  font-size: 16px;
  width: 70%;
  padding: 0 10px;
  background: #fff;
  color: #666;
  border: 1px solid #e5e5e5;
  transition: .2s ease-in-out;
  transition-property: color,background-color,border;
 }

.input-container {
  width: 100%;
  height: 60px;
  margin-bottom: 20px;
  display: inline-block;
}

.label {
  width: 25%;
  padding-top: 8px;
  display: inline-block;
  text-align: center;
  text-transform: uppercase;
  font-weight: bold;
  height: 34px;
  border-top-left-radius: 4px;
  border-bottom-left-radius: 4px;
  background: rgb(238, 238, 238);
}

.primary {
  background-color: #1e87f0;
}

.primary:hover {
  background-color: #0f7ae5;
  color: #fff;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>


<div id='root'>
</div>

Or... have 3 separate inputs and combine them when done.

const validateInput = value => {
  let error = ""
  
  if (!value) error = "Required!"
  else if (value.length !== 14) error = "Invalid phone format. ex: (555) 555-5555";
  
  return error;
};

const initialState = {
 areaCode: "",
 prefix: "",
 suffix: "",
 error: ""
};
    
class Form extends React.Component {
  constructor() {
    super();
    
    this.state = initialState;
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.handleReset = this.handleReset.bind(this);
    this.setInputRef = this.setInputRef.bind(this);
  }
  
  handleChange({ target: { name, value } }) {
    let valueChanged = false;
    this.setState(prevState => {
      const nextValue = value.replace(/[^\d]/g, '');
      const changedValue = prevState[name];
      if (changedValue.length !== nextValue.length) valueChanged = true;
  
      return { [name]: nextValue }
    }, () => {
      if(valueChanged) this.handleFocus(name)
    });
  };
  
  setInputRef(name, element) {
   this[name] = element;
  }
  
  handleFocus(name){
    const { areaCode, prefix, suffix } = this.state;
    const areaCodeFilled = areaCode.length === 3;
    const prefixFilled = prefix.length === 3;

    if(areaCodeFilled && name === "areaCode") {
      this.prefix.focus();
      this.prefix.selectionEnd = 0;
    } else if(prefixFilled && name === "prefix") {
      this.suffix.focus();
      this.suffix.selectionEnd = 0;
    }
  }
  
  handleSubmit(e) {
    e.preventDefault();
    const { areaCode, prefix, suffix } = this.state;
    const phoneNumber = `(${areaCode}) ${prefix}-${suffix}`
    const error = validateInput(phoneNumber);
    
    this.setState({ error }, () => {
       if(!error) {
         setTimeout(() => {
           alert(phoneNumber);
         }, 300)
       }
    });
  }
  
  handleReset() {
     this.setState(initialState);
  };
  
  render() {
    return(
      <form className="form" onSubmit={this.handleSubmit}>
        <div className="input-container">
          <div className="label">
            Phone:
          </div>
          <div className="parenthesis" style={{ marginLeft: 10, marginRight: 2}}>&#40;</div>
          <input
            className="input area-code"
            type="text"
            name="areaCode"
            placeholder="xxx"
            value={this.state.areaCode}
            onChange={this.handleChange}
            maxLength="3"
          />
           <div className="parenthesis" style={{ marginRight: 2}}>&#41;</div>
          <input
            ref={node => this.setInputRef("prefix", node)}
            className="input prefix"
            type="text"
            name="prefix"
            placeholder="xxx"
            value={this.state.prefix}
            onChange={this.handleChange}
            maxLength="3"
          />
          <div className="dash">-</div>
          <input
            ref={node => this.setInputRef("suffix", node)}
            className="input suffix"
            type="text"
            name="suffix"
            placeholder="xxxx"
            value={this.state.suffix}
            onChange={this.handleChange}
            maxLength="4"
          />
        </div>
         <p className="error">{this.state.error}</p>
        <div className="btn-container">
          <button 
             className="btn danger"
             type="button"
             onClick={this.handleReset}
           >
            Reset
          </button>
          <button className="btn primary" type="submit">Submit</button>
        </div>
      </form>
    );
  }
}

ReactDOM.render(
  <Form />,
  document.getElementById('root')
);
html {
  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
  font-size: 16px;
  font-weight: 400;
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
  background: #fff;
  color: #666;
}

.btn {
  color: #fff;
  border: 1px solid transparent;
  margin: 0 10px;
  cursor: pointer;
  text-align: center;
  box-sizing: border-box;
  padding: 0 30px;
  vertical-align: middle;
  font-size: .875rem;
  line-height: 38px;
  text-align: center;
  text-decoration: none;
  text-transform: uppercase;
  transition: .1s ease-in-out;
  transition-property: color,background-color,border-color;
}

.btn:focus {
  outline: 0;
}

.btn-container {
  text-align: center;
  margin-top: 10px;
}

.form {
  width: 550px;
  margin: 0 auto;
}

.danger {
  background-color: #f0506e;
  color: #fff;
  border: 1px solid transparent;
}
 
.danger:hover {
  background-color: #ee395b;
  color: #fff;
}

.error {
  margin: 0;
  height: 24px;
  margin-top: -20px;
  padding-left: 26%;
  color: red;
  text-align: right;
}

.input {
  display: flex;
  height: 40px;
  font-size: 16px;
  width: 33%;
  padding: 0 3px;
  background: #fff;
  color: #666;
  outline: none;
  border: 0;
}
 
.area-code,.prefix {
 width: 27px;
}

.suffix {
 width: 38px;
}

.dash,.parenthesis {
 display: flex;
}

.input-container {
  width: 100%;
  margin-bottom: 20px;
  display: flex;
  flex-direction: row;
  align-items: center;
  border-top-left-radius: 4px;
  border-bottom-left-radius: 4px;

    border: 1px solid #e5e5e5;
  transition: .2s ease-in-out;
  transition-property: color,background-color,borde
}

.label {
  height: 100%;
  background: rgb(238, 238, 238);
  width: 25%;
  padding-top: 8px;
  display: flex;
  text-transform: uppercase;
  justify-content: space-around;
  font-weight: bold;
  height: 34px;
}

.primary {
  background-color: #1e87f0;
}

.primary:hover {
  background-color: #0f7ae5;
  color: #fff;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>


<div id='root'>
</div>