Frontend Development 15 min read

React as a State Management Library: Best Practices and Patterns

This article explains how React can serve as a complete state‑management solution by using hooks, lifting state, component composition, the Context API, and optional libraries, while also covering performance tips, server‑cached versus UI state, and when to adopt external tools.

Architects Research Society
Architects Research Society
Architects Research Society
React as a State Management Library: Best Practices and Patterns

React as a State Management Library

Managing state is often the most difficult part of building an application, which is why many state‑management libraries exist; however, with the introduction of React hooks and significant improvements to the Context API, React itself now provides a comprehensive solution.

Redux became popular because react‑redux solved the "prop‑drilling" problem by allowing components to share data through a connect function, but overusing Redux for both global and local state can lead to tangled code and performance issues.

Instead of placing every piece of state in a single store, you can lift state to the nearest common ancestor and pass it down as props, keeping the state close to where it is needed.

Simple counter example using useState :

function Counter() {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);
  return <button onClick={increment}>{count}</button>;
}

function App() {
  return <Counter />;
}

When multiple components need the same state, lift the state to a parent component and pass it as props:

function Counter({count, onIncrementClick}) {
  return <button onClick={onIncrementClick}>{count}</button>;
}

function CountDisplay({count}) {
  return <div>The current counter count is {count}</div>;
}

function App() {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);
  return (
    <div>
      <CountDisplay count={count} />
      <Counter count={count} onIncrementClick={increment} />
    </div>
  );
}

If prop‑drilling becomes cumbersome, the Context API offers an official way to share state without passing props through every intermediate component.

// src/count/count-context.js
import * as React from 'react';
const CountContext = React.createContext();
function useCount() {
  const context = React.useContext(CountContext);
  if (!context) {
    throw new Error('useCount must be used within a CountProvider');
  }
  return context;
}
function CountProvider(props) {
  const [count, setCount] = React.useState(0);
  const value = React.useMemo(() => [count, setCount], [count]);
  return <CountContext.Provider value={value} {...props} />;
}
export { CountProvider, useCount };
// src/count/page.js
import * as React from 'react';
import { CountProvider, useCount } from './count-context';
function Counter() {
  const [count, setCount] = useCount();
  const increment = () => setCount(c => c + 1);
  return <button onClick={increment}>{count}</button>;
}
function CountDisplay() {
  const [count] = useCount();
  return <div>The current counter count is {count}</div>;
}
function CountPage() {
  return (
    <div>
      <CountProvider>
        <CountDisplay />
        <Counter />
      </CountProvider>
    </div>
  );
}

For more complex scenarios you can replace useState with useReducer inside the context, giving you a predictable state‑transition model:

function countReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    default:
      throw new Error(`Unsupported action type: ${action.type}`);
  }
}
function CountProvider(props) {
  const [state, dispatch] = React.useReducer(countReducer, { count: 0 });
  const value = React.useMemo(() => [state, dispatch], [state]);
  return <CountContext.Provider value={value} {...props} />;
}
function useCount() {
  const context = React.useContext(CountContext);
  if (!context) {
    throw new Error('useCount must be used within a CountProvider');
  }
  const [state, dispatch] = context;
  const increment = () => dispatch({ type: 'INCREMENT' });
  return { state, dispatch, increment };
}

Key guidelines:

Not every piece of data belongs in a single global store; keep logically separate concerns in separate contexts.

Provide state as close to the components that need it as possible; avoid unnecessary global exposure.

State can be divided into two broad categories: server‑cached state (data fetched from a server) and UI state (local UI concerns such as modal visibility). Treat them differently; for server‑cached data consider using tools like React Query, while UI state can often be handled with useState or useReducer combined with context when needed.

Performance tips include splitting state into smaller logical units, optimizing context providers, and, for advanced cases, exploring atomic state libraries such as jotai or Recoil. However, most applications do not require these external tools.

In conclusion, keep state as local as possible, use hooks for simple cases, lift state when sharing is required, and only resort to Context or external libraries when prop‑drilling becomes a real problem; this approach leads to easier maintenance and better performance.

frontendPerformanceState ManagementReactHooksContext API
Architects Research Society
Written by

Architects Research Society

A daily treasure trove for architects, expanding your view and depth. We share enterprise, business, application, data, technology, and security architecture, discuss frameworks, planning, governance, standards, and implementation, and explore emerging styles such as microservices, event‑driven, micro‑frontend, big data, data warehousing, IoT, and AI architecture.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.