--forcepushed--fp
  • Home
  • Articles
  • Resources
  • Projects

Build smarter, ship faster, and stand out from the crowd.

Subscribe or follow on X for updates when new posts go live.

Follow on X

The Evolution of React Input Handling (From useRef to Context)

At this stage, I’ve spent the majority of my software career working with React. With that has come a lot of time wiring up input fields—text inputs, selects, checkboxes, entire forms, and the glue code that sits between user interaction and application state.

Over the years, I’ve seen a wide range of patterns emerge. I’ve also interviewed front-end and full-stack candidates on these exact fundamentals, watched them struggle through “simple” input handling questions, and observed how these patterns show up (or don’t) during live coding sessions.

Each example below intentionally builds on the previous one. The goal isn’t to say “this is the correct way”—it’s to show how engineers tend to evolve their thinking over time, and where that evolution often stalls.

I want to take some time today and walk through these patterns, from the most basic to the point where you’re essentially building your own form abstraction.

The basic uncontrolled input

This is one of the most deceptively simple patterns in React—and one that routinely stumps even seasoned front-end candidates in interviews.

An uncontrolled input relies entirely on the DOM to hold its value. React doesn’t track changes, doesn’t re-render on keystrokes, and doesn’t care what the current value is unless you explicitly ask for it. This makes it extremely useful for simple form submissions, one-off inputs, or cases where you don’t need real-time validation or side effects.

The key idea: no React state is involved at all.

import { useRef } from "react";

function App() {
  const inputRef = useRef(null);

  return (
    <input
      defaultValue="Jane Doe"
      ref={inputRef}
      type="text"
    />
  );
}

This pattern is lightweight, performant, and often overlooked. If all you need is to read the value on submit, this is frequently the correct solution—not a shortcut.

The basic controlled input

This is the pattern most developers first learn from the React documentation.

Here, the input’s value is fully controlled by React state. Every keystroke triggers an onChange event, updates state, and causes a re-render. This gives you total visibility into the input’s value at all times, which is essential for validation, formatting, and conditional UI behavior.

import { useState } from "react";

function App() {
  const [name, setName] = useState("Jane Doe");

  return (
    <input
      onChange={(e) => setName(e.target.value)}
      type="text"
      value={name}
    />
  );
}

This approach is explicit and easy to reason about. The tradeoff is verbosity and performance—especially once forms grow beyond one or two fields.

Separate onChange action per input field

This is where many applications—and many engineers—stop evolving their approach.

Instead of inline handlers, the logic is extracted into named functions. This improves readability, testability, and reuse, but doesn’t fundamentally change the architecture.

import React, { useState } from 'react';

function App() {
  const [name, setName] = useState('');

  const handleOnChangeName = (e) => {
    setName(e.target.value);
  };

  return (
    <input
      onChange={handleOnChangeName}
      type="text"
      value={name}
    />
  );
}

While this is cleaner, it scales poorly. Once you have five or ten inputs, you end up with a swarm of nearly identical handlers—and a growing sense that something isn’t quite right.

Shared onChange action for multiple input fields

This is usually the first real optimization engineers discover on their own.

Instead of one handler per field, a single handler updates a shared state object. The input’s name attribute becomes the key that drives updates.

import React, { useState } from 'react';

function App() {
  const [inputs, setInputs] = useState({
    email: 'janedoe@company.com',
    name: 'Jane Doe',
  });

  const handleOnChangeInput = (e) => {
    setInputs({
      [e.target.name]: e.target.value
    });
  };

  return (
    <>
      <input
        name='email'
        onChange={handleOnChangeInput}
        type="text"
        value={inputs.email}
      />
      <input
        name='name'
        onChange={handleOnChangeInput}
        type="text"
        value={inputs.name}
      />
    </>
  );
}

This pattern dramatically reduces duplication and is often “good enough” for small-to-medium forms. It also introduces the idea that inputs are data-driven rather than hardcoded.

Separate onChange event handler per input field using React reducer

At this point, engineers start reaching for useReducer, usually motivated by more complex state transitions or a desire for explicit action semantics.

Conceptually, this example isn’t very different from earlier patterns—it just formalizes updates into actions.

import React, { useReducer } from 'react';

function reducer(state, action) {
  switch (action.type) {
    case 'ON_CHANGE_NAME':
      return {
        ...state,
        name: action.value,
      };

    default:
      return state;
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, {
    name: 'Jane Doe',
  });

  const handleOnChangeName = (e) => {
    dispatch({
      type: 'ON_CHANGE_NAME',
      value: e.target.value,
    });
  };

  return (
    <input
      onChange={handleOnChangeName}
      type="text"
      value={state.name}
    />
  );
}

This approach shines when updates are more than simple assignments—but for basic forms, it often adds ceremony without much payoff.

Shared onChange action for multiple input fields using React reducer

This is the reducer-based equivalent of the shared handler pattern earlier.

A single action type updates any input, keyed by name. This brings consistency and scales better as forms grow.

import React, { useReducer } from 'react';

function reducer(state, action) {
  switch (action.type) {
    case 'ON_CHANGE_INPUT':
      return {
        ...state,
        [action.name]: action.value,
      };

    default:
      return state;
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, {
    email: 'janedoe@company.com',
    name: 'Jane Doe',
  });

  const handleOnChangeInput = (e) => {
    dispatch({
      name: e.target.name,
      type: 'ON_CHANGE_INPUT',
      value: e.target.value,
    });
  };

  return (
    <>
      <input
        name='email'
        onChange={handleOnChangeInput}
        type="text"
        value={state.email}
      />
      <input
        name='name'
        onChange={handleOnChangeInput}
        type="text"
        value={state.name}
      />
    </>
  );
}

At this stage, most engineers feel they’ve reached a “professional” solution. But there’s still a lot of plumbing happening at the component level.

Use React Context to create input consumer + provider

This pattern emerges when you get tired of passing value, onChange, defaults, and error state through every layer of your component tree.

The motivation here is simple: inputs shouldn’t care where their data comes from. They should only need to know what they represent.

At this point, you’re dangerously close to inventing your own form library—and many engineers do, often multiple times across different codebases.

import React, { useReducer } from 'react';

const InputContext = React.createContext({})

function Input (p) {
  return (
    <input
      name={p.name}
      onChange={p.onChange}
      type='text'
      value={p.value}
    />
  )
}

const InputConsumer = (Component) => (p) => (
  <InputContext.Consumer>
    {(context) => {
      const {
        name = '',
        ...remProps
      } = p

      const { 
        inputs = {},
        onChange = () => {}
      } = context

      const value = inputs[name] || ''

      return (
        <Component
          {...remProps}
          name={name}
          onChange={onChange}
          value={value}
        />
      )
    }}
  </InputContext.Consumer>
)

const ConsumedInput = InputConsumer(Input)

const InputProvider = (p) => (
  <InputContext.Provider
    value={{
      inputs: p.inputs,
      onChange: p.onChange,
    }}>
    {p.children}
  </InputContext.Provider>
)

function reducer(state, action) {
  switch (action.type) {
    case 'ON_CHANGE_INPUT':
      return {
        ...state,
        [action.name]: action.value,
      };

    default:
      return state;
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, {
    email: 'janedoe@company.com',
    name: 'Jane Doe',
  });

  const handleOnChangeInput = (e) => {
    dispatch({
      name: e.target.name,
      type: 'ON_CHANGE_INPUT',
      value: e.target.value,
    });
  };

  return (
    <InputProvider onChange={handleOnChangeInput} inputs={state}>
      <ConsumedInput name='email' />
      <ConsumedInput name='name' />
    </InputProvider>
  );
}

Once you reach this stage, you’ve effectively built the foundation of a form system. From here, it’s a short leap to validation layers, schemas, async rules, and eventually… deciding whether you should’ve just used a library in the first place.

Final thoughts

None of these patterns are wrong. Each one exists because it solves a real problem at a specific scale.

What matters most—especially in interviews and real-world codebases—is knowing when to stop. Overengineering forms is easy. Underengineering them is even easier.

The real skill is recognizing which tradeoffs you’re making—and why.