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 Situation | Recommended Approach | Reason |
|---|---|---|
| Single component needs the data | Local component state | Simplest option, and the easiest to test |
| Siblings need to share data | Lift state to common parent | Keeps data close to where it’s used |
| Data needed throughout the tree | Dependency injection, if updates are infrequent | Avoids prop drilling |
| API data with caching needs | Server state library | Built for caching and syncing server data |
| Complex forms with validation | Form library | Handles validation and error states well |
| Complex coordination across many components | Global state library | Gives 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.