Skip to main content
Saved
Guide strategy Intermediate 8 min read

Choosing a State Management Approach

How to choose between local state, lifted state, dependency injection, and global state libraries, based on what your application needs.

State management is one of those areas where teams often pick a complex solution before they understand what they actually need. Whether it’s a simple marketing site or a large collaborative tool, teams that start simple and only add more structure when they need it tend to ship faster, and find their code easier to maintain, than teams that start with a heavyweight solution.

Picking the tools is fairly straightforward once you understand what kind of state you have and where it should be kept.

Assess Your Situation First

Before you pick a state management approach, it helps to look at a few things about your app and your team:

  • Application complexity: A form with a few inputs has different needs than a real-time collaborative editor. It’s easy to add centralized state management to an application that would be simpler with local component state, and then you take on the extra overhead without getting anything for it.
  • Team size: When several developers work on the same codebase, centralized state can prevent conflicts and make it easier to trace how data moves through the app. If your team is small, the extra coordination might not be worth the overhead.
  • State characteristics: Different types of state have different lifecycles. Server data that can go stale behaves differently from UI state that resets when a component unmounts.
  • Performance requirements: Some approaches trigger more re-renders than others. In most applications the difference is too small to notice, but for performance-sensitive interfaces it’s worth thinking about early.

Understand the Types of State

I find it helpful to categorize state by its source and lifecycle, because different categories benefit from different approaches.

UI state includes whether a dropdown is open or which tab is selected. This state is short-lived, and it usually doesn’t need to be passed far from the component that uses it.

Form state includes field values and validation errors. Forms need validation and a way to track which fields have changed, and general-purpose state tools don’t handle either of those well.

Server state is data that comes from an API and is cached locally. It can go stale, and other users might change it while you’re looking at it. A lot of bugs come from treating server state the same way as client state.

Client state is data your application owns entirely, such as user preferences or draft content that hasn’t been saved yet. This is the kind of state that belongs in a client-side store.

URL state is kept in the address bar, and it needs special treatment because users can share and bookmark it. If users would be frustrated to lose something when they refresh the page, it probably belongs in the URL.

Evaluate the Core Options

Local Component State

Component State keeps data inside a single component, using the framework’s built-in primitives. Every modern framework supports this, through hooks or reactive references.

Choose local state when:

  • Only one component needs the data
  • It’s fine for the state to reset when the component unmounts
  • You want the simplest possible implementation

Keep it manageable: Local state is the easiest kind of state to test and reason about. I recommend defaulting to local state, and only moving data somewhere else when you have a specific reason to. Moving from local to shared state is straightforward, but going the other way is harder.

Lifted State

State Lifting moves state up to a common ancestor when several components need to share it. The parent owns the state and passes it down as props, along with callbacks the children can call to update it. This pattern works the same way in every framework.

Choose lifted state when:

  • Two or more sibling components need the same data
  • The shared state has a clear common ancestor
  • Prop drilling stays shallow, usually three levels or fewer

Recognize when to lift: If you find yourself duplicating the same state in several components, or passing callbacks through a parent only to coordinate its children, it’s probably time to lift that state.

Dependency Injection

Every framework gives you a way to pass data down through several layers of components without passing it through each component in between. React calls this Context, Vue uses provide/inject, Angular has its dependency injection system, and Svelte offers context, which in Svelte 5 can share rune-based reactive state as well as stores.

Choose dependency injection when:

  • Data is app-wide, such as the theme or the authenticated user
  • The data doesn’t change often
  • Many components throughout the tree need access

Avoid common pitfalls: In React, every component that reads a context re-renders when the context’s value changes. If you put state that changes often into this layer, such as form inputs or loading indicators, it can cause performance problems and make your app noticeably slower. Signal-based frameworks such as Vue, Svelte 5, and Angular keep closer track of what reads each value, and they only update the parts that use the value that changed, so the problem is worst in React.

Global State Libraries

Dedicated state management libraries give you a structure for managing state across your whole application, and they help you coordinate updates in ways the simpler approaches can’t. In the React ecosystem there’s Redux, Zustand, MobX, and Jotai. Vue has Pinia, Angular has NgRx, and XState and Effector work with any framework.

Choose a state library when:

  • Many disconnected components need the same data
  • State updates come from several sources and you need to trace how the data moves
  • You need developer tools for debugging complex state transitions
  • Several developers are changing the same state and need clear conventions

Evaluate the cost: These libraries add concepts your team has to learn and code you have to maintain. The extra coordination really does help, but many applications never need that much of it.

Server State Libraries

Some libraries are built specifically for caching and syncing data from APIs. They take care of things that general-purpose state libraries don’t handle well, such as caching, removing duplicate requests, refetching in the background, and optimistic updates. TanStack Query works with React, Vue, Solid, and Svelte. For GraphQL there’s Apollo Client and urql, and SWR is for React applications. Each framework’s ecosystem has options of its own.

Choose a server state library when:

  • Your application fetches data from APIs
  • Multiple components display the same server data
  • You need caching with automatic refetching

Separate concerns: Server state and client state have different lifecycles, and they get updated in different ways, so both are easier to manage when you keep them in separate systems. I recommend using a server state library for API data and a simpler approach for everything else.

Form Libraries

Form libraries take care of validation and submitting the form. In React there’s React Hook Form, TanStack Form, and Formik. Vue has VeeValidate and FormKit, Angular has its own built-in form handling, and Svelte has Superforms. These libraries are built on best practices that would take a lot of effort to get right yourself.

Choose a form library when:

  • Forms have many fields or complex validation rules
  • Fields depend on each other
  • You need async validation or multi-step flows

Start simple: For a form with two or three fields and basic validation, local state works fine. A form library is worth it once your forms are complex enough to justify learning its API.

Combine Approaches When It Makes Sense

In my experience, most applications benefit from using multiple approaches for different types of state:

  • Local state for UI state, such as toggles and selected tabs
  • Lifted state for data shared between a few related components
  • Server state library for API data that needs caching and refetching
  • Form library for complex forms with validation
  • Dependency injection sparingly, for app-wide data that doesn’t change often
  • Global state library only when coordinating state gets complex enough to need one

Quick Decision Guide

Your SituationRecommended ApproachReason
Single component needs the dataLocal component stateSimplest option, and the easiest to test
Siblings need to share dataLift state to common parentKeeps data close to where it’s used
Data needed throughout the treeDependency injection, if updates are infrequentAvoids prop drilling
API data with caching needsServer state libraryBuilt for caching and syncing server data
Complex forms with validationForm libraryHandles validation and error states well
Complex coordination across many componentsGlobal state libraryGives complex data flow a clear structure

Signals That Your Approach Needs to Change

There are a few signs that your app has outgrown the approach you’re using:

  • Prop drilling through many layers: If props pass through three or more components that don’t use them, you might want to use dependency injection or lift the state higher.
  • Duplicated state: If the same data is stored in more than one place and the copies get out of sync, you should move it into one place.
  • Performance problems from injected state: If updates to injected state cause visible delays, it’s worth moving that state to a more granular solution.
  • Multiple injection points becoming unwieldy: If you have four or five separate injection points that interact in complex ways, a dedicated state library can give you a clearer structure.
  • State transitions with complex rules: If an update has to coordinate changes across several pieces of state, a reducer or state library lets you keep that logic in one place.

It makes sense to move to something more structured when your current approach is already causing you problems. If you’re only expecting problems later, it’s usually better to wait until they actually happen.

Decision Checklist

Before you commit to an approach, it helps to work through these questions:

What type of state is this? UI state, form state, server state, and client state each behave differently, so you’ll want an approach that suits the type you have.

How many components need it? One component suggests local state, and a few related components suggest lifted state. Once many disconnected components need it, dependency injection or a library fits better.

How often does it change? State that changes often can cause performance problems when you inject it across a large part of the tree, so it’s better to use a more granular solution for that data.

Does it come from an API? Server data benefits from a library that’s built for caching and refetching it.

Can you migrate later? If components access state through functions or composables, you can change the underlying implementation later without rewriting the components that use it.

Next steps: It makes sense to start with the simplest approach that does what you need. If you document why you made each choice, future team members can follow the reasoning, and when your approach starts causing problems, it’s fine to revisit the decision and switch as your application grows.

Next Guide · 11 min read

Designing Component APIs

How to choose between props, slots, render props, compound components, and other patterns when you're designing a component's interface.

Read the next guide →

Newsletter

A monthly email
from Den Odell.

Behind-the-scenes thinking on frontend patterns, site updates, and more.

No spam. Unsubscribe anytime.