Paulund
Subscribe

Sharing state in React without Redux

24 October 2026, 5 min read

On this page
  1. Lifting state up
  2. How far up to lift
  3. Context with TypeScript
  4. Where Context gets slow
  5. Zustand for shared client state
  6. React Query for server data
  7. When Redux still makes sense

Most React apps don't need Redux. The right tool depends on the kind of state. Local UI state, shared app state and server data are three different problems, and treating them all the same is where the complexity creeps in.

The short version:

  • State used by one component: useState.
  • State two nearby components share: lift it to their closest common parent.
  • A low-frequency value many components read, such as a theme: Context.
  • Client state that changes often or is needed anywhere: Zustand, or Jotai, which takes a similar atom-based approach.
  • Data from a server: React Query or SWR.

Lifting state up

When two components need the same data, move the state to their closest common ancestor. Syncing state between siblings, or reaching for a global store, is usually the wrong first move.

Take a temperature converter with a Celsius input and a Fahrenheit input. If each input keeps its own state, typing in one has no effect on the other:

TSX
function CelsiusInput() {
  const [celsius, setCelsius] = useState("");
  return <input value={celsius} onChange={(e) => setCelsius(e.target.value)} />;
}

The fix is to remove local state from the inputs. Each input takes its value and a change handler as props, and the parent holds the state:

TSX
interface TemperatureInputProps {
  value: string;
  onChange: (value: string) => void;
}

function TemperatureInput({ value, onChange }: TemperatureInputProps) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

function TemperatureConverter() {
  const [celsius, setCelsius] = useState("");

  const fahrenheit =
    celsius !== "" ? String((parseFloat(celsius) * 9) / 5 + 32) : "";

  return (
    <div>
      <TemperatureInput value={celsius} onChange={setCelsius} />
      <TemperatureInput
        value={fahrenheit}
        onChange={(f) => setCelsius(String(((parseFloat(f) - 32) * 5) / 9))}
      />
    </div>
  );
}

TemperatureConverter is now the single source of truth, and the conversion logic lives in one place. Fahrenheit is derived from Celsius, so it needs no state of its own.

Both inputs are now controlled components: they show whatever value the parent gives them. A component that keeps its own state is uncontrolled. Both have their place, but when two parts of the screen must stay in sync you need the controlled version. Controlled and uncontrolled forms covers the form side of this.

Lifted-out components get simpler and easier to test, because you can render them with any value and handler. The parent gets a little more complex, but you can see the state and how its children relate without opening each child file.

How far up to lift

Lift only as far as you need. Find the closest common ancestor of every component that uses the state and put it there.

Text
App
└── Layout          ← selectedId lives here
    ├── Sidebar     ← receives selectedId, onSelect
    └── MainPanel   ← receives selectedId

If the state ends up several levels above the components that use it, you start passing props through components that ignore them:

Text
Layout              ← selectedId
└── PageBody        ← passes it down, doesn't use it
    └── List        ← passes it down, doesn't use it
        └── ListItem  ← uses it

That is prop drilling. Try composition with children first, which often removes the middle layers. If many components across the tree still need the value, use Context.

Context with TypeScript

Context broadcasts a value to every component below a provider, without passing it as a prop at each level. It doesn't have to be app-wide. You can have several contexts, each covering a different subtree.

It suits values many components read but few change, such as a theme, the logged-in user or a locale.

First, create the context with a null default:

TSX
import { createContext, useContext, useState } from "react";

type Theme = "light" | "dark";

type ThemeContextValue = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

A null default means a component that reads the context outside a provider gets null, which is a clear sign of a wiring mistake. A real default would hide the mistake behind a stale value.

Then write a provider component that holds the state:

TSX
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  function toggleTheme() {
    setTheme((t) => (t === "light" ? "dark" : "light"));
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

After that, add a custom hook that throws when there is no provider:

TSX
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

Calling useTheme() outside a <ThemeProvider> now fails straight away with a readable error.

Wrap the subtree that needs the value:

TSX
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Any component inside can read it directly:

TSX
function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header data-theme={theme}>
      <button onClick={toggleTheme}>
        {theme === "light" ? "Switch to dark" : "Switch to light"}
      </button>
    </header>
  );
}

Every context can follow this shape: one createContext call with a null default, one provider holding the state, and one hook that throws outside the provider. Anyone reading a new context file knows where everything is.

When the provider's state logic grows, Context combines well with useReducer. The provider holds the reducer and passes state and dispatch down.

Where Context gets slow

Every consumer re-renders when the context value changes. Passing an object literal as value creates a new object on every render of the provider, so all consumers re-render even when the data is the same:

TSX
// New object on every render
<ThemeContext.Provider value={{ theme, toggleTheme }}>

For a theme that changes rarely, that's fine. For fast-changing data, like a form field updating on every keystroke, a cursor position or a live counter, it becomes a real cost. Stabilise the value with useMemo and useCallback:

TSX
const toggleTheme = useCallback(() => {
  setTheme((t) => (t === "light" ? "dark" : "light"));
}, []);

const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

Even then, a consumer can't subscribe to one field of the value. If you need that, or the state updates often, use a store.

Zustand for shared client state

For client state that changes often, drives a lot of UI, or is needed anywhere, a small store like Zustand fits better than Context:

TSX
// store/cart.ts
import { create } from "zustand";

type CartItem = { id: string; name: string; quantity: number };

type CartStore = {
  items: CartItem[];
  add: (item: CartItem) => void;
  remove: (id: string) => void;
};

export const useCartStore = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
  remove: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
}));

A component subscribes to just the slice it needs, so unrelated updates don't re-render it. There is no provider, and the store is a hook:

TSX
function CartButton() {
  const count = useCartStore((state) => state.items.length);
  return <button>Cart ({count})</button>;
}

You don't need action creators, reducers or a middleware pipeline to track a cart or a modal stack.

React Query for server data

Data fetched from an API isn't really your state. It is a cache of server state that might be stale. Fetching it in useEffect and storing it in useState leads to loading flickers, stale data, duplicate requests and manual cache invalidation.

React Query (TanStack Query) handles that by default:

TSX
// hooks/use-products.ts
import { useQuery } from "@tanstack/react-query";

type Product = { id: string; name: string; price: number };

async function fetchProducts(): Promise<Product[]> {
  const res = await fetch("/api/products");
  if (!res.ok) throw new Error("Failed to fetch products");
  return res.json();
}

export function useProducts() {
  return useQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
}
TSX
function ProductList() {
  const { data, isLoading, error } = useProducts();

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Something went wrong.</p>;

  return (
    <ul>
      {data?.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

You get caching, deduplication, background refetching and error handling with almost no setup. Mutations, pagination and optimistic updates are built in. If a Redux store holds mostly server data, React Query can replace it.

When Redux still makes sense

Redux earns its place when a large team needs strict one-way data flow with a full audit trail, complex derived state across many slices, or time-travel debugging in the devtools. Those are real requirements. Most apps don't have them.

For most projects, useState covers local UI, Context covers app-wide config such as theme and auth, Zustand covers shared interactive state, and React Query covers everything from the network.

Next in React

24 October 2026useState, useReducer and the mental model of state in React

Tags

  • React
  • State management
  • Context
  • TypeScript