
Local state in React components is often the first stop when managing data that changes over time. It’s straightforward: you declare a piece of state with useState, update it when needed, and React takes care of re-rendering. This simplicity is powerful because it keeps state encapsulated within the component, making it easier to reason about and test.
However, that simplicity comes with some built-in constraints. Local state is confined to the component where it’s declared. If you need multiple components to share or synchronize data, local state quickly becomes cumbersome. You end up passing state values down through props or callbacks, which can spiral into “prop drilling”-a tangled web of intermediate components that only exist to forward data.
Another limitation is that local state updates are asynchronous and batched by React, which means you can’t always immediately read the updated value after calling a setter function. This can lead to subtle bugs if your logic depends on the latest state right after an update.
When state grows more complex-say, you have multiple related values that change together or depend on specific actions-local state also tends to become verbose or error-prone. For example, managing form input fields with independent useState calls can get messy:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
This approach works in simple scenarios but doesn’t scale well. Each field requires its own state and handler, increasing boilerplate and making it harder to perform validations or reset the entire form.
When state dependencies become intricate, it’s easy to introduce bugs like inconsistent updates or stale closures. This often signals that a different approach might better fit the problem, especially if the state transitions are driven by distinct events or actions.
Using local state also means that if the component unmounts, the state is lost. This is fine in many cases, but if you want to persist data across navigations or share it globally, local state isn’t sufficient. At that point, you might start looking at context, state management libraries, or patterns that lift state higher up the component tree.
Understanding these limitations is key to knowing when local state is enough and when it becomes a liability. It’s not about abandoning useState but recognizing the signs that your app’s complexity demands more structured state management.
10 Pack Silicone Bands Compatible with Apple Watch 38mm 40mm 41mm 42mm 44mm 45mm 46mm 49mm Women Men, Soft Waterproof Replacement Wrist Sport Band for iWatch Series 11 10 9 8 7 6 5 4 3 2 1 SE Ultra
Now retrieving the price.
(as of June 3, 2026 23:09 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Choosing between useState and useReducer for complexity
When deciding between useState and useReducer, the primary consideration is the complexity and nature of the state transitions. useState excels for simple, independent pieces of state that can be updated in isolation. However, once the state involves multiple sub-values that must change in a coordinated way, or when actions trigger complex updates, useReducer becomes more appropriate.
useReducer centralizes state logic into a single reducer function, which takes the current state and an action, then returns a new state. This pattern mirrors Redux but without the external library overhead. It encourages a clear separation of state transitions from UI code, making the updates more predictable and easier to test.
Consider a form that tracks multiple fields along with validation states and submission status. Using useState, you might end up with many state variables and handlers:
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
With useReducer, you can consolidate this into one state object and handle all updates through dispatched actions:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
This approach makes the flow of state changes explicit and grouped by action types. Adding new state transitions usually means extending the reducer rather than adding more state variables and handlers. It also reduces the likelihood of bugs from partially updated states.
Another benefit is that useReducer is well-suited for scenarios where the next state depends on the previous state in a non-trivial way. Because the reducer function always receives the current state, there is no risk of stale closures that sometimes occur with multiple useState calls.
For example, if you want to implement undo/redo functionality or batch multiple state updates atomically, useReducer provides a natural foundation. You can keep a history stack in the state and manage it through dispatched actions:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
On the other hand, useState retains advantages in simplicity and readability when the state is minimal and updates are straightforward. The mental overhead of writing reducer logic and action dispatching isn’t justified if you only toggle a boolean or update a single input value.
In summary, the choice hinges on the shape and complexity of your state. If you find yourself juggling multiple interrelated state variables or needing explicit control over how updates happen, useReducer is likely the better fit. For isolated, simple state changes, useState remains the pragmatic default.
It’s also worth noting that these hooks aren’t mutually exclusive. You can combine useState and useReducer within the same component or across components, using each where it makes the most sense. This hybrid approach often yields the best balance between simplicity and structure.
Finally, while useReducer provides more structure, it can make components more verbose and less intuitive if overused. The reducer function can grow large and unwieldy if it tries to handle too many unrelated actions. In such cases, splitting logic into multiple reducers or moving state management outside the component-via context or external state libraries-may be necessary to keep code maintainable.
Choosing the right tool is less about strict rules and more about recognizing the patterns your application exhibits. As state complexity rises, the cost of scattered useState calls often outweighs the initial investment in reducer-based design.
Next, when shared state between components becomes a concern, lifting state up is the canonical React pattern. Instead of duplicating state or relying on context prematurely, moving state to the closest common ancestor ensures a single source of truth and avoids synchronization pitfalls.
For example, consider two sibling components that need to display and update the same piece of data. If each manages its own local state, inconsistencies arise:
function SiblingA({ count, setCount }) {
return <button onClick={() => setCount(count + 1)}>Increment</button>;
}
function SiblingB({ count }) {
return <div>Count: {count}</div>;
}
By lifting the count state to their parent, both receive the same data and update function:
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<SiblingA count={count} setCount={setCount} />
<SiblingB count={count} />
</div>
);
}
This pattern avoids duplication and keeps state changes predictable. If the state were deeper or shared across many components, context or state management libraries might be warranted, but lifting state up is almost always the first step.
However, lifting state can lead to “prop drilling” when intermediate components only serve to pass down props. At this point, alternative patterns like context or custom hooks can help organize and encapsulate shared state logic without bloating the component tree.
The key takeaway is that managing shared state effectively starts with placing it at the nearest common ancestor component. This ensures a single source of truth and clear ownership, which simplifies debugging and reasoning about your app’s behavior.
When combined with useReducer, lifted state can gain even more structure. For instance, the parent component can hold the reducer and pass dispatch functions down to children to trigger state changes, preserving a clean and maintainable data flow.
Consider a todo list where multiple items can be added, toggled, or removed. The parent component manages the list state with a reducer:
const initialTodos = [];
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.text, completed: false }];
case 'toggle':
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
case 'remove':
return state.filter(todo => todo.id !== action.id);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, initialTodos);
return (
<div>
<TodoInput onAdd={text => dispatch({ type: 'add', text })} />
<TodoList todos={todos} onToggle={id => dispatch({ type: 'toggle', id })} onRemove={id => dispatch({ type: 'remove', id })} />
</div>
);
}
This design cleanly separates state management from presentation. Child components receive only the data and callbacks they need, while the parent maintains control over the full state lifecycle.
It’s important to balance where you lift state. Moving it too high can unnecessarily complicate your component tree, while keeping it too low may cause duplication or synchronization issues. The ideal point is the closest common ancestor that all consumers share.
When state must be shared globally or across distant branches, React’s context API can provide a convenient mechanism to avoid excessive prop drilling. But context should complement, not replace, thoughtful state placement.
In complex applications, combining lifted state, useReducer, and context often forms the backbone of robust state management before reaching for external libraries. This layered approach lets you incrementally add structure as your app grows, maintaining clarity and control over your data flows.
As your application expands beyond local or lifted state, you might also explore memoization techniques like useMemo and useCallback to optimize rendering performance. These become increasingly important when passing down callbacks or derived data to deeply nested children, preventing unnecessary re-renders that can degrade user experience.
For example, memoizing event handlers in the parent component:
const increment = useCallback(() => dispatch({ type: 'increment' }), [dispatch]);
And memoizing derived data that depends on state:
const completedCount = useMemo(() => todos.filter(todo => todo.completed).length, [todos]);
These optimizations work hand-in-hand with state management strategies to keep your app responsive and maintainable. However, they should be applied judiciously, as premature optimization can add unnecessary complexity.
The interplay between useState, useReducer, lifting state, and context forms the foundational toolkit for managing React state effectively. Mastering when and how to use each enables you to build applications that remain comprehensible as they scale, avoiding the pitfalls of tangled or duplicated state.
Next, we will explore specific patterns for lifting state up to manage shared data effectively, including practical examples of refactoring components and handling callbacks to maintain synchronization across the UI.
Starting with a simple scenario: two components need to share a counter value. Initially, they each have independent state:
function CounterA() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count A: {count}</button>;
}
function CounterB() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count B: {count}</button>;
}
This causes the counters to diverge. To synchronize, lift the state to a parent:
function ParentCounter() {
const [count, setCount] = useState(0);
return (
<div>
<CounterA count={count} setCount={setCount} />
<CounterB count={count} setCount={setCount} />
</div>
);
}
function CounterA({ count, setCount }) {
return <button onClick={() => setCount(count + 1)}>Count A: {count}</button>;
}
function CounterB({ count, setCount }) {
return <button onClick={() => setCount(count + 1)}>Count B: {count}</button>;
}
By lifting state, both counters reflect the same value and update in unison. This pattern scales to more complex data but can introduce verbose prop passing if many components require access.
To address prop drilling, you might introduce context:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
0
Context reduces the need to thread props through intermediate components but can obscure data flow if overused. It’s best reserved for truly shared state that many components consume.
When lifting state, consider also how you pass update functions. Passing setters directly can be convenient but may encourage components to mutate state arbitrarily. Instead, passing explicit callbacks or dispatch functions encourages clearer intent and easier debugging.
For example, instead of passing setCount directly, pass an increment callback:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
1
This clarifies the intent and can simplify future changes, such as adding logging or validation inside the increment function without modifying child components.
Ultimately, lifting state up is about finding the balance between encapsulation and sharing. The goal is a predictable, single source of truth that keeps your UI consistent without excessive boilerplate or complexity. As your app grows, combining lifted state with reducer patterns and context will help you maintain that balance.
Next, we will examine real-world refactoring examples where lifting state reduces bugs and improves component reuse, focusing on practical techniques to pass data and events cleanly through the React component tree.
Imagine a scenario with a parent component managing a list of items and two children: one to add items, another to display them. Initially, the add component maintains its own input state and calls a callback to append the item:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
2
The parent holds the list state and passes an onAdd handler:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
3
This clear separation allows each component to focus on its responsibility. The add form manages its own input state, while the parent orchestrates the list. The display component simply renders the list:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
4
Here, lifting state to the parent avoids duplication and keeps data flow unidirectional. It also makes it easier to extend functionality, such as adding item removal or editing, by centralizing state mutations.
As applications grow, this pattern scales naturally by lifting state to the nearest common ancestor that requires access, then passing data and callbacks down. When prop drilling becomes cumbersome, context or state management libraries can help, but the fundamental principle remains: share state by lifting it up.
In the next section, we will explore how to refactor deeply nested component trees to minimize prop drilling and improve maintainability by combining lifted state with context and custom hooks, striking a balance between encapsulation and accessibility.
One common pitfall when lifting state is over-lifting: moving state too far up the tree, which forces unrelated components to re-render unnecessarily. To mitigate this, consider splitting state into smaller chunks or using memoization techniques.
For example, if only a subset of components depend on a particular piece of state, lift that state just above those components rather than all the way to the root. This localizes re-renders and improves performance.
Additionally, React’s React.memo and useMemo can help prevent unnecessary re-renders in child components receiving props that don’t change:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
5
By carefully lifting state and optimizing renders, you maintain both clarity and performance. This approach forms the backbone of scalable React applications before considering more complex state management solutions.
Finally, the act of lifting state is not just a technical refactor but a design decision. It reflects the data ownership and flow in your app, making explicit the dependencies between components and the lifecycle of your data.
This clarity aids debugging, testing, and future development, as you can trace state changes through a well-defined path rather than scattered local variables or ad-hoc synchronization.
When you combine this with disciplined use of useReducer for complex state transitions and context for shared state, you build a solid foundation that scales with your application’s needs and complexity.
Next, we will dive into advanced patterns for lifting state, including splitting reducers, co-locating state with components, and integrating with external state management libraries while preserving React’s declarative nature.
To begin, consider a complex form with nested fields that require validation and conditional logic. Instead of lifting all state to a single parent, you can split state into logical sections, each managed by its own reducer or useState hook, then compose these pieces together.
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
6
This modular approach keeps each reducer focused and manageable, while the parent coordinates overall behavior. It also facilitates reuse of sections in other parts of the app.
Similarly, you can co-locate state with the components that primarily use it, lifting only what must be shared. This hybrid strategy balances encapsulation with sharing and avoids unnecessary complexity.
When integration with external state management libraries like Redux or Zustand becomes necessary, these principles still apply. The key is to keep state ownership clear, minimize duplication, and maintain predictable data flows.
For example, a Redux store can replace useReducer in the parent, with components dispatching actions and selecting state via hooks, while local UI state remains managed with useState:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
7
This separation of concerns leverages Redux for global, shared state and React hooks for component-local state, providing a clean architecture.
In conclusion, choosing between useState and useReducer, and deciding when and where to lift state, are fundamental decisions shaping your application’s structure and maintainability. Understanding these trade-offs and applying them thoughtfully leads to React codebases that are both robust and adaptable.
Next, we will examine concrete refactoring exercises demonstrating these concepts in action, focusing on transforming tangled local state into clean, shared state managed via lifting and reducers.
One practical tip when lifting state is to ensure handlers passed down to child components are stable references. Unstable callbacks cause unnecessary re-renders and can break memoization. Use useCallback to memoize these functions:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
8
Similarly, avoid passing new object literals or inline functions as props unless wrapped in useMemo or useCallback. This discipline preserves render performance and predictable behavior.
Additionally, when lifting state, consider the shape of the data. Flatten deeply nested state objects where possible to simplify updates and reduce the chance of bugs from shallow merges or stale nested references.
For example, instead of:
const initialState = {
username: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'fieldChange':
return {
...state,
[action.field]: action.value,
};
case 'submit':
return {
...state,
isSubmitting: true,
error: null,
};
case 'submitSuccess':
return {
...state,
isSubmitting: false,
};
case 'submitError':
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
9
Consider flattening:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
0
This simplifies update logic and reduces complexity when lifting state, especially when multiple components only need parts of the state.
When using useReducer, normalize your state shape similarly, and use helper functions to update nested data immutably, reducing boilerplate and improving clarity:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
1
Incorporating such utilities into your reducer can keep it concise and focused on intent rather than implementation detail.
Finally, when lifting state, testability improves dramatically. With a single source of truth and explicit update functions, unit tests can target state transitions directly, and UI tests can focus on rendering and interaction without worrying about inconsistent state.
For example, testing a reducer independently:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
2
This test isolation is harder to achieve with scattered local state, making useReducer and lifted state an ally in maintaining code quality.
We will now proceed to practical examples of lifting state in nested components, demonstrating how to refactor existing codebases to adopt these patterns with minimal disruption and maximal clarity.
Imagine a component tree where a deeply nested child needs to update state owned by a distant ancestor. Passing setters through many layers is tedious and error-prone. Lifting state up alone won’t solve this elegantly; instead, you can combine lifting with context to provide access to state and dispatch functions where needed.
For example, define a context for the shared state:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
3
Then, any descendant can consume the context directly:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
4
This pattern avoids prop drilling, keeps state ownership clear, and simplifies deeply nested updates.
When combined with useReducer, the provider can expose the state and a dispatch function instead:
const initialState = {
past: [],
present: { count: 0 },
future: [],
};
function reducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'increment':
return {
past: [...past, present],
present: { count: present.count + 1 },
future: [],
};
case 'undo':
if (past.length === 0) return state;
const previous = past[past.length - 1];
return {
past: past.slice(0, past.length - 1),
present: previous,
future: [present, ...future],
};
case 'redo':
if (future.length === 0) return state;
const next = future[0];
return {
past: [...past, present],
present: next,
future: future.slice(1),
};
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
5
This design scales well for complex state shared across many components, combining the benefits of lifting, reducers, and context.
However, be mindful that context updates cause all consumers to re-render when the context value changes. To mitigate performance issues, split context into smaller, focused providers or use memoized selectors with libraries like use-context-selector.
In summary, the interplay of useState, useReducer, lifting state, and context forms a flexible toolkit. Understanding their strengths and trade-offs empowers you to design React applications with clear, maintainable, and efficient state management strategies.
Next, we will explore how to encapsulate shared state logic into custom hooks, further improving reusability and separation of concerns while preserving the principles discussed so far.
Lifting state up to manage shared data effectively
When lifting state up, it’s essential to consider how to handle updates efficiently. Instead of passing down setters or individual state values, you can create a more structured approach by providing action handlers as props. This allows child components to communicate their intent without needing to know the specifics of the parent’s state structure.
For instance, instead of passing down a setCount function directly, you can abstract the update logic into a handler that describes the action:
function Parent() {
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount(c => c + 1);
};
return (
<div>
<CounterA onIncrement={incrementCount} count={count} />
<CounterB count={count} />
</div>
);
}
function CounterA({ onIncrement, count }) {
return <button onClick={onIncrement}>Count A: {count}</button>;
}
function CounterB({ count }) {
return <div>Count B: {count}</div>;
}
This approach encapsulates the logic of how the count is incremented and makes it clear what each child component is responsible for. The child components can focus on their UI responsibilities, while the parent manages the state.
Another consideration when lifting state is how to handle derived state. If a component needs to compute values based on the lifted state, it’s beneficial to do this in the parent and pass the computed values down as props. This avoids unnecessary recalculations in child components and keeps them lean:
function Parent() {
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount(c => c + 1);
};
const doubledCount = count * 2;
return (
<div>
<CounterA onIncrement={incrementCount} count={count} />
<CounterB count={doubledCount} />
</div>
);
}
This technique not only simplifies the child components but also ensures that they receive only the data they need to render, thus optimizing performance.
As your application grows, consider implementing a custom hook to encapsulate the state logic. This allows for reusable stateful logic across different components, reducing boilerplate and improving maintainability.
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
function Parent() {
const { count, increment } = useCounter();
return (
<div>
<CounterA onIncrement={increment} count={count} />
<CounterB count={count} />
</div>
);
}
This example demonstrates how a custom hook can abstract away the state management logic, allowing components to focus solely on rendering. The useCounter hook can be reused in any component that requires similar functionality, promoting DRY (Don’t Repeat Yourself) principles.
When dealing with more complex state that involves multiple values or actions, consider using a reducer within the custom hook. This combines the benefits of local state management with the structured approach of reducers, enabling clearer state transitions:
function useTodoList() {
const initialState = [];
function reducer(state, action) {
switch (action.type) {
case 'add':
return [...state, action.payload];
case 'remove':
return state.filter(todo => todo.id !== action.payload.id);
default:
return state;
}
}
const [todos, dispatch] = useReducer(reducer, initialState);
const addTodo = (todo) => dispatch({ type: 'add', payload: todo });
const removeTodo = (id) => dispatch({ type: 'remove', payload: { id } });
return { todos, addTodo, removeTodo };
}
function TodoApp() {
const { todos, addTodo, removeTodo } = useTodoList();
return (
<div>
<TodoInput onSubmit={addTodo} />
<TodoList todos={todos} onRemove={removeTodo} />
</div>
);
}
This pattern encapsulates the entire todo list logic within a custom hook, allowing the TodoApp component to remain clean and focused on rendering. The reducer provides a clear structure for managing complex state transitions while keeping the logic reusable.
In summary, lifting state effectively involves not just moving state up in the component tree but also considering how to manage updates, derive values, and encapsulate logic. By applying these techniques, you can maintain a clean and efficient state management strategy that scales with your application.
Next, we will delve into advanced patterns for custom hooks, exploring how to manage side effects and asynchronous operations in conjunction with lifted state, ensuring a responsive and cohesive user experience.
