Subscribe or follow on X for updates when new posts go live.
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.
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.
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.
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.
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.
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.
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.
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.
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.