State Management in React: Patterns for Expo Apps
Master state management in React with practical patterns for Expo and React Native apps. Compare Context, Redux, Zustand, and Recoil with real trade-offs.

The popular advice is simple: put shared state in a global store early, then let the rest of the app read from it. That advice made sense when React teams commonly treated server responses, navigation, authentication, UI controls, and domain data as one large state problem. In a modern Expo app, it often creates more work than it removes.
State management in React works better as a classification problem. A modal toggle belongs near the modal. Cached profile data belongs with a server-state tool. A deep-linkable filter belongs in navigation state. Only shared client state deserves a store, and even then, the smallest tool that provides clear subscriptions usually wins.
That shift matters on mobile. Every unnecessary render, provider dependency, hydration decision, and persistence rule can affect how smooth an interaction feels. The practical question isn't “Which library is best?” It's “What kind of state is this, who owns it, and what should happen when the app loses focus or comes back online?”
Table of Contents
- Why Most React Apps Have Too Much Global State
- The Four Layers of React State
- Comparing Context, Redux, Zustand, and Recoil
- How External Stores and Snapshots Actually Work
- Managing URL State and Navigation in Expo Apps
- AppLighter's Opinionated State Management Stack
- Common Mistakes and How to Avoid Them
Why Most React Apps Have Too Much Global State
Every app needs state. Not every app needs global state.
A local form value, an open sheet, a selected tab, or a temporary animation flag usually has one clear owner. Moving it into Redux, Zustand, or a broad Context provider creates a second responsibility: components now need to understand the store's API, selectors, persistence behavior, and update flow even though the value only matters inside one feature.
Recent guidance increasingly separates local UI state, shared client state, and server state, with the practical conclusion that many teams need less state management rather than a larger library. Current React state-management coverage specifically frames TanStack Query as a home for server data and Zustand or Context as options for shared client state.
The hidden price of centralization
Centralization looks tidy in a diagram. In production, it can spread dependencies through the component tree.
A screen that reads a global object may re-render when an unrelated property changes, depending on how the store or provider exposes subscriptions. A feature that could have accepted a value through a small prop interface may instead depend on a store module, middleware, persistence configuration, and test setup. A server response placed in a client store can also become stale without the cache invalidation, refetching, retry, and loading semantics that server-state tools are built to handle.
Expo teams feel this cost when a screen combines navigation transitions, remote data, keyboard interactions, and frequent gestures. The issue isn't always the library itself. It's often that too many responsibilities update through one broad channel.
Practical rule: Before adding a value to a store, identify at least two unrelated consumers that need to read or change it.
A better promotion test
Keep state local when its lifecycle follows one component or feature. Promote it only when ownership becomes awkward.
Look for these signals:
- Prop drilling across unrelated branches: A value passes through components that don't use it.
- Duplicated synchronization: Multiple screens maintain their own copy and can disagree.
- Cross-feature commands: A toolbar, drawer, background task, and screen all need to trigger the same client-side action.
- Persistence with a clear product reason: The app must restore a preference or draft after navigation or relaunch.
- Derived UI that has several owners: Multiple areas need the same computed client value.
This isn't an argument against Redux or global stores. Large teams may need strict update conventions, auditability, and mature debugging tools. The point is to make global state earn its place. A smaller state surface is easier to profile, test, persist, and replace.
The Four Layers of React State
A useful Expo architecture starts by assigning each value to one of four layers. The layers aren't competing libraries. They're different ownership models.
An infographic titled The Four Layers of React State, illustrating local, shared, server, and navigation state.
Local component state
Use useState for a small, direct value such as whether a bottom sheet is open, which input currently has focus, or whether a password is visible. The state belongs to the component because another feature doesn't need to know about it.
Use useReducer when a feature has several related transitions. A multi-step form, a media composer, or a checkout section may be easier to reason about when events update one local state machine instead of scattering setters across handlers.
Local state should normally disappear with the feature that owns it. That lifecycle is valuable. It prevents stale values from surviving navigation because a global store still holds them.
Shared client state
Shared client state is data created and controlled by the app, not fetched as the authoritative record from a backend. A shopping cart, a selected workspace, a draft editor, or a cross-screen display preference can fit here.
Context works well when the value changes infrequently and has a natural provider boundary, such as theme or authentication status. A store such as Zustand is useful when unrelated components need focused subscriptions without a large provider hierarchy. Redux remains an option when a team values strict conventions and centralized event history.
The key is scope. A cart may be shared across product and checkout screens, but a temporary product-card hover equivalent on mobile shouldn't become app-wide state just because it could.
Server state
A user profile fetched from Supabase, a feed, permissions returned by an API, and remote mutations are server state. The backend owns the authoritative value, while the app manages freshness, loading, errors, retries, caching, invalidation, and optimistic transitions.
That responsibility is different from storing a local boolean. TanStack Query or SWR can represent the lifecycle directly instead of forcing a general-purpose client store to imitate a cache.
If two screens request the same profile, they should share a query identity and cache policy rather than each owning a manually synchronized copy. Mutations should also have an explicit invalidation or update strategy so the UI doesn't drift from the backend.
URL and navigation state
Expo Router makes navigation part of the app's addressable state. Route segments, search parameters, selected filters, and deep-linkable tabs can describe what a screen should show.
A list filter is a good example. If the filter affects a shareable screen, belongs in a deep link, or should survive navigating away and returning, keeping it only in a component or global store creates unnecessary synchronization. Recent React architecture guidance for 2026 treats URL state as its own layer alongside local, server, and shared client state, with specialized tools preferred over one universal store.
React's ecosystem moved through distinct waves, from Flux in 2014, to Redux in 2015, MobX in 2016, Context in 2018, Hooks in 2019, and newer hook-centric tools around 2019 to 2020, according to this history of React state-management libraries. The lesson isn't that older tools became useless. It's that React teams kept separating concerns as the framework's primitives improved.
Comparing Context, Redux, Zustand, and Recoil
These tools solve overlapping problems, but they impose different operating costs. The right choice depends on update frequency, team size, debugging needs, and how much structure the codebase requires.
| Library | Bundle Size Impact | Best For | Re-render Control | Learning Curve |
|---|---|---|---|---|
| Context | Low by itself | Theme, auth status, stable feature-scoped values | Broad updates unless the context is split carefully | Low |
| Redux | Adds a structured runtime and setup layer | Teams needing predictable conventions and debugging | Strong with deliberate selectors | Moderate |
| Zustand | Lightweight store approach | Shared client state with a small API | Focused selectors | Low |
| Recoil | Adds an atom-based model | Teams that prefer atomic and derived state | Fine-grained atom subscriptions | Moderate |
Context
Context is built into React, which makes it a natural first tool for values with a clear scope. A theme provider, locale, or session status can be easy to consume and test.
The problem starts when a single context value contains unrelated, frequently changing fields. Consumers subscribe to the context value, not to an arbitrary property inside it. Splitting providers and memoizing values can help, but a large context tree becomes difficult to understand and maintain.
For Expo apps, Context is a good fit when the provider expresses a real boundary. It's a poor fit when developers use it as a universal replacement for a store.
Redux
Redux offers a strong mental model: actions describe events, reducers calculate transitions, and selectors read state. Redux Toolkit reduces the ceremony compared with older Redux patterns, while the ecosystem provides mature debugging and established team conventions.
That structure is useful when many developers touch the same domain and consistency matters more than minimal setup. It can feel excessive for a small product with a handful of shared client values, especially if the store also contains API caching that belongs elsewhere.
Redux isn't automatically slow. Poor selectors, broad subscriptions, and expensive derived calculations can create performance problems in any architecture. Measure the update path before blaming the library.
Zustand
Zustand's appeal is its small surface area. Components can select the specific slice they need, and teams don't have to build a large action and provider framework for a small amount of shared client state.
That flexibility is also the main risk. Redux gives a team stronger conventions by default. Zustand lets each team invent its own conventions, so naming actions, separating domains, testing transitions, and deciding persistence boundaries require discipline.
For many Expo products, that trade-off is reasonable. Shared client state tends to stay understandable when the store contains UI coordination and client-owned data, while TanStack Query handles remote data separately. The TanStack Query, Zustand, and Redux comparison for React Native is useful when making that split explicit.
Recoil
Recoil uses atoms and selectors to model independent state units and derived values. That can make narrowly scoped updates expressive, especially when a feature has a graph of related values.
The trade-off is ecosystem confidence and team familiarity. Atomic models introduce concepts that every contributor must understand, and the architecture can become fragmented if atom boundaries aren't designed carefully. For a new Expo app, Recoil should be a deliberate choice based on the team's preferred model, not a default response to re-render concerns.
For broader background on how enterprise web teams evaluate React application architecture, the ReactPy tutorial for enterprises offers useful adjacent context, even though ReactPy isn't a substitute for these React Native state tools.
How External Stores and Snapshots Actually Work
A library can look simple at the hook level while relying on a carefully designed subscription mechanism underneath. React's useSyncExternalStore provides the contract that lets React consume state held outside React's own component state.
A diagram illustrating how external stores and snapshots work in React with the useSyncExternalStore hook.
The flow is straightforward:
- The store owns the current value and notifies subscribers.
- A snapshot function returns the value React should read.
useSyncExternalStoresubscribes to changes and reads snapshots during rendering.- The component renders from the selected value.
React reads a snapshot during render, then reads it again before commit. If the store changes while React is rendering, React can restart synchronously so components don't commit an inconsistent view. The official useSyncExternalStore RFC describes this tearing-prevention behavior and its trade-off: updates from an external store don't use concurrent time-slicing, so a large external-store update may feel more blocking than a local React-state update.
Snapshot identity is part of correctness
A custom store's getSnapshot() function must return a cached, stable value when the underlying state hasn't changed. React compares the current snapshot with the previous one using Object.is.
Returning a new object on every call tells React that the snapshot changed every time, even when its contents are identical. That can cause repeated updates and, in the wrong implementation, an infinite loop. The practical guidance in this custom React state-management implementation makes the issue concrete.
A safe pattern is to update the snapshot only when data changes:
let state = { count: 0 };
let snapshot = state;
const listeners = new Set();
function getSnapshot() {
return snapshot;
}
function setState(nextState) {
if (Object.is(nextState, state)) return;
state = nextState;
snapshot = state;
listeners.forEach((listener) => listener());
}
The exact implementation will vary, but the principle doesn't. Use structural sharing, preserve references for unchanged branches, and memoize derived snapshots. If a selector creates a fresh array or object on every render, it can defeat the stability that the subscription layer depends on.
For React Native debugging, inspect both sides of the problem. Confirm that the store emits only when meaningful data changes, then confirm that components select stable, narrow values. A store that notifies too broadly and a component that allocates fresh derived objects can multiply each other's cost.
Managing URL State and Navigation in Expo Apps
Mobile apps don't always show a traditional browser address bar, but navigation still carries state. Expo Router route segments and search parameters can describe a screen, its filters, and the path needed to restore it.
A close-up of a person holding a smartphone displaying a URL filter and deep linking settings screen.
A catalog screen might use a route parameter for the category and search parameters for sorting or filtering. That gives the screen a reproducible input. A shared link can open the same view, and a navigation action can preserve the user's place without copying those values into a separate store.
Keep each navigation concern in its own lane
Route state answers where the user is. URL state answers which variation of that screen is active. Auth state answers whether the session is valid and which protected area the user can access. These concerns interact, but they shouldn't become one mutable object.
A practical Expo arrangement looks like this:
- Expo Router: Owns route segments, deep links, and navigation transitions.
- Session provider or auth hook: Owns the current session and refresh behavior.
- Query layer: Fetches data using route and search parameters as part of query identity.
- Local state: Controls temporary UI such as an open filter sheet.
- Shared client store: Holds values needed by unrelated screens.
Auth redirects should respond to the session source of truth, not to a duplicated isLoggedIn flag that can fall out of sync. Likewise, a filter should update the route or search parameters directly rather than writing to a store and then running an effect that tries to mirror the store into navigation.
The Expo Router versus React Navigation guide can help teams choose the navigation model before they design persistence and deep-link behavior around it.
Here's a short visual reference for the relationship between navigation inputs and screen state:
On mobile, persistence needs a product decision. Restore a filter when continuity matters, but don't persist transient presentation state merely because storage is available. Deep links should contain enough meaningful context to rebuild the screen, while sensitive session material should remain under the auth system rather than in shareable navigation parameters.
AppLighter's Opinionated State Management Stack
AppLighter uses a deliberately separated stack for Expo applications: Zustand for shared client state, TanStack Query for server state with the Supabase adapter, and Expo Router for navigation and URL-linked state. The value of this arrangement isn't that one library solves everything. It's that each tool has a bounded job.
Screenshot from https://www.applighter.com
Why the split works
Zustand is appropriate for app-owned values such as a cart, a draft, a selected workspace, or a client-side preference used by several unrelated components. Keep those stores domain-specific. A useCartStore should not become the home for fetched products, session refresh state, and navigation flags.
TanStack Query handles remote data differently. A Supabase query can expose loading and error states, reuse cached results, invalidate data after a mutation, and support optimistic UI without turning every response into permanent client-owned state. The query key should reflect the remote resource and the route inputs that define it.
Expo Router supplies the navigation layer. A list's category, search term, or selected route can remain addressable instead of being copied into Zustand. This reduces synchronization code and gives the app a clearer recovery path when a screen remounts.
AppLighter organizes state-management logic in the /lib directory, alongside its pre-configured authentication and navigation layers. That arrangement gives a team a starting convention without requiring every feature to place state in one root store. Teams evaluating starter kits can also consider how reducing time to market affects their choice of pre-wired architecture.
Production patterns for mobile behavior
For an optimistic update, update the visible query cache immediately, send the mutation, then either reconcile with the server response or roll back on failure. Keep a client-only draft in Zustand or local state, but don't treat the optimistic representation as authoritative forever.
Offline-first behavior needs similar separation. Persist the minimum client-owned data required to restore a user workflow, queue mutations according to product rules, and let the server-state layer decide when remote data is stale or should be refetched. A disconnected screen shouldn't present an old server response as if it were current.
The result is an opinionated baseline rather than a universal mandate. A regulated enterprise team may choose Redux for governance. A highly complex workflow may justify a state-machine approach. For many Expo products, separating remote data, navigation inputs, and shared client state prevents the central store from becoming an accidental second backend.
Common Mistakes and How to Avoid Them
The most common failure is storing API responses in a general client store because the first fetch works. Later, screens need invalidation, retries, optimistic updates, or a consistent loading state, and the team has to build a cache inside the store. Move remote data to TanStack Query or another server-state solution, then keep only client-owned decisions in Zustand, Context, or local React state.
A second mistake is making every convenient value global. If only one screen needs a toggle, keep it in that screen. If two child components need it, lift it to their nearest useful parent. Introduce Context or a store when the ownership boundary crosses the tree.
A third mistake is waiting for performance trouble before examining subscriptions. Profile the interaction, inspect which components re-render, and check whether selectors return stable values. Don't replace a library until you know whether the actual issue is a broad selector, fresh derived objects, an expensive calculation, or an update that should have stayed local.
A practical decision flow
- Does a backend or remote service own the data? Use a server-state layer.
- Does navigation or sharing need to reproduce it? Put it in route or URL state.
- Does one feature own it? Use
useStateoruseReducer. - Do unrelated components need the same client-owned value? Use Context for a small, stable concern or a focused store for more active shared state.
- Does the team need strict event conventions and deep inspection? Evaluate Redux Toolkit before choosing a lighter store.
React's modern state-management shift accelerated when Hooks arrived in React 16.8 in 2019, allowing state and other React features without classes. React Native support began with version 0.59, making hook-based patterns practical across web and mobile codebases, as documented in this React Hooks history reference. The durable lesson is architectural, not fashionable: keep ownership clear, give remote data a cache, let navigation describe navigable state, and make global state justify its cost.
AppLighter gives Expo teams a pre-configured foundation with Zustand for shared client state, React Query for server data, authentication, navigation, and supporting app architecture already organized for production work. Visit AppLighter to start with those boundaries in place instead of rebuilding the same state-management decisions inside every new app.