Share state across components the right way — lift state to a common parent, provide app-wide values with Context, combine Context with useReducer for a dependency-free store, and reach for Zustand when state changes often.
Why: when two siblings need the same data, the state moves to their closest common parent — one owns it, both receive it. This is the first tool to reach for; most "state management" problems dissolve right here.
'use client'
import { useState } from 'react'
// The child doesn't own the state — it reports changes up via a callback prop
function Filter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />
}
function Results({ query }: { query: string }) {
return <p>Results for: {query || '(everything)'}</p>
}
// Both siblings need query → the parent owns it
export default function Page() {
const [query, setQuery] = useState('')
return (
<>
<Filter value={query} onChange={setQuery} />
<Results query={query} />
</>
)
}Why: for values the whole tree needs but that rarely change — the signed-in user, theme, locale. Wrap the plumbing in a provider component plus a custom hook so consumers stay one-liners. Note: every consumer re-renders when the value changes, so keep high-frequency state out of context.
'use client'
import { createContext, useContext, useState } from 'react'
type Auth = {
user: string | null
signIn: (name: string) => void
signOut: () => void
}
const AuthContext = createContext<Auth | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<string | null>(null)
return (
<AuthContext value={{ user, signIn: setUser, signOut: () => setUser(null) }}>
{children}
</AuthContext>
)
}
// The custom hook hides the plumbing and catches misuse
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
return ctx
}
// Anywhere below the provider:
function Profile() {
const { user, signIn, signOut } = useAuth()
if (!user) return <button onClick={() => signIn('ada')}>Sign in</button>
return <button onClick={signOut}>Sign out ({user})</button>
}Why: combine two hooks you already know and you get a complete state manager with zero extra packages — useReducer keeps all the update logic in one function, and context hands the state and the dispatch function to any component that needs them. This is the built-in answer to "do I need Redux?" for small apps. Tip: put state and dispatch in two separate contexts — buttons that only send actions won't re-render every time the data changes.
'use client'
import { createContext, useContext, useReducer } from 'react'
type Task = { id: number; text: string; done: boolean }
// Every way the list can change, described as plain objects
type Action = { type: 'added'; text: string } | { type: 'toggled'; id: number }
// All update logic lives here — components never edit the list directly
function tasksReducer(tasks: Task[], action: Action): Task[] {
switch (action.type) {
case 'added':
return [...tasks, { id: Date.now(), text: action.text, done: false }]
case 'toggled':
return tasks.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
)
}
}
// Two contexts: one carries the data, one carries dispatch
const TasksContext = createContext<Task[]>([])
const TasksDispatchContext = createContext<React.Dispatch<Action>>(() => {})
export function TasksProvider({ children }: { children: React.ReactNode }) {
const [tasks, dispatch] = useReducer(tasksReducer, [])
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
)
}
export const useTasks = () => useContext(TasksContext)
export const useTasksDispatch = () => useContext(TasksDispatchContext)
// Anywhere below the provider:
function AddTask() {
// Only uses dispatch — won't re-render when tasks change
const dispatch = useTasksDispatch()
return (
<button onClick={() => dispatch({ type: 'added', text: 'New task' })}>
Add
</button>
)
}
function TaskList() {
const tasks = useTasks()
const dispatch = useTasksDispatch()
return (
<ul>
{tasks.map((t) => (
<li key={t.id} onClick={() => dispatch({ type: 'toggled', id: t.id })}>
{t.done ? '✓ ' : '○ '} {t.text}
</li>
))}
</ul>
)
}Why: for client state that changes often and is read in many places — carts, filters, UI panels — context re-renders every consumer. A Zustand store lives outside React, needs no provider, and each component subscribes to just the slice it selects.
$ pnpm add zustand'use client'
import { create } from 'zustand'
type CartStore = {
items: string[]
add: (item: string) => void
clear: () => void
}
// The store lives OUTSIDE React — no provider needed
const useCart = create<CartStore>((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}))
function AddButton() {
const add = useCart((s) => s.add) // select only what you need
return <button onClick={() => add('Book')}>Add book</button>
}
function CartBadge() {
// Re-renders ONLY when items.length changes
const count = useCart((s) => s.items.length)
return <span>Cart: {count}</span>
}