State management in frontend development deals with maintaining the state or data knowledge across multiple application components. It’s an essential concept while working with frontend JavaScript and TypeScript frameworks and libraries, especially React, React Native, Vue, and Angular.
Dealing with state management in TypeScript brings extra benefits, as TypeScript provides type safety. We can define types in each state management singleton and use the type definition with our modern code editors to show errors and write code with the correct syntax and format.
Although most state management solutions are biased toward React, there are a few for other frontend frameworks, and the number of solutions available continues to rise. In this article, we will compare some commonly used state management solutions in TypeScript and see their basic examples.
State management plays a crucial role in building modern web applications. It ensures that data is well-organized, updated, and efficiently shared across different parts of the application.
When developers explore various TypeScript state management options, they often seek features that address common challenges while keeping things flexible. Let’s discuss some of the challenges that come with state management and some essential features you might need from the solution you choose.
As your projects get bigger, dealing with state complexity can be challenging. Finding a solution that simplifies things without giving up flexibility is crucial.
Maintaining a uniform structure across different application parts is critical for predictable behavior and a user-friendly experience. When handling updates simultaneously and avoiding conflicts, especially in applications with asynchronous tasks, caution and attention to detail are key.
With TypeScript becoming more popular, developers emphasize state management to catch errors early on during compilation rather than waiting for them to appear at runtime.
Ideally, your state management solution should make modifying and updating the application state easy, simplifying code debugging and understanding. By centralizing the application state, you gain better control and monitoring, allowing you to track changes better and reducing the risk of inconsistencies.
Embracing a reactive paradigm allows components to automatically update with changes in the underlying state, minimizing the need for manual adjustments to keep the user interface in sync.
Additionally, it’s essential for state management solutions to scale smoothly with the application’s complexity and size, ensuring optimal performance as the project grows.
Finally, developer tools such as time-travel debugging and state inspection add valuable insights into the application’s state at different points in time, enhancing the development process.
For TypeScript applications, type safety is of the utmost importance. A state management solution that seamlessly integrates with TypeScript ensures that developers fix type-related errors early in the development process, enhancing code quality and minimizing runtime issues.
As applications scale, the value of TypeScript’s type safety becomes more apparent. A state management solution that scales well with TypeScript promotes efficient collaboration among developers and facilitates the maintenance of large codebases.
In the next few sections, we’ll highlight the following TypeScript state management libraries:
We’ll go over which frameworks these state management solutions are for and some examples of how to install and use them.
We can use React Redux and Redux Toolkit in our React and React Native projects. The React Redux library allows us to seamlessly integrate the Redux state management tool with React apps. Meanwhile, Redux Toolkit further simplifies the configuration and usage of Redux in our projects.
Redux has many features and benefits, especially when using TypeScript with React. Some of these include:
To install it, simply run the command below:
npm install @reduxjs/toolkit react-redux
Let’s see some examples of how to use Redux Toolkit and React Redux.
Let’s create a file named src/lib/store.ts
and copy in the following code:
import { combineReducers, configureStore } from '@reduxjs/toolkit'; import counterReducer from './features/counter/counterSlice'; const rootReducer = combineReducers({ counter: counterReducer, }); export default configureStore({ reducer: rootReducer, }); export type IRootReducer = ReturnType<typeof rootReducer>;
This creates a Redux store and automatically configures the Redux DevTools extension so we can inspect the store while developing. We will implement the counterReducer
later in this tutorial.
Once the store is created, we can make it available to our Redux Toolkit components by putting a React Redux <Provider>
around our application in src/index.tsx
. Let’s import the Redux store we just created, add the <Provider>
in the <App>
component, and pass the store as a prop:
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { App } from './App'; import store from './lib/store'; const root = createRoot(document.getElementById('app')); root.render( <StrictMode> <Provider store={store}> <App /> </Provider> </StrictMode> );
Let’s add a new file named counterSlice.ts
in the src/lib/features/counter/
directory. In this file, let’s bring in the createSlice
API from the Redux Toolkit.
To make a slice, we should give it a name, an initial state, and one or more reducers to determine how the state can change. Once the slice is created, we can export the Redux action creators it generates and the entire slice’s reducer function.
With Redux Toolkit’s createSlice
and createReducer
, we can write our state updates like we are making changes directly, even though Redux demands immutable updates:
import { createSlice } from '@reduxjs/toolkit'; export const counterSlice = createSlice({ name: 'counter', initialState: { value: 0, }, reducers: { increment: (state) => { state.value += 1; }, decrement: (state) => { state.value -= 1; }, incrementByAmount: (state, action) => { state.value += action.payload; }, }, }); export const { increment, decrement, incrementByAmount } = counterSlice.actions; export default counterSlice.reducer;
The code above sets up a simple counter in a React application and defines a Redux state slice to manage the counter’s state.
Now, we can use the React Redux hooks to let React components interact with the Redux store. We can read data from the store using useSelector
and dispatch actions using useDispatch
.
Let’s modify our App.tsx
component like below to show the counter and increase and decrease its value:
import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { decrement, increment } from './lib/features/counter/counterSlice'; import { IRootReducer } from './lib/store'; export function App() { const count = useSelector<IRootReducer, number>( (state) => state.counter.value ); const dispatch = useDispatch(); return ( <div> <div> <button aria-label="Increment value" onClick={() => dispatch(increment())} > Increment </button> <span>{count}</span> <button aria-label="Decrement value" onClick={() => dispatch(decrement())} > Decrement </button> </div> </div> ); }
The Counter
component we set up here interacts with the counterSlice
component we created previously to access the current state of the counter
slice and display and modify its values in the component.
You can check out this example in the StackBlitz repo.
Although we focused on the React app in this article, we can use Redux in almost all JavaScript and TypeScript view libraries. For more information on getting started on Redux state management, you can visit the Redux Getting Started page.
MobX is a well-tested library for managing states in a simple and scalable way using functional reactive programming. The idea is to keep the code minimal and straightforward.
If we want to update a field in a record, we can use a regular JavaScript assignment, and MobX will update everything else automatically. It also ensures efficient rendering and tracking of changes to our data at runtime to only update what’s necessary, saving us from manual optimizations like memoization.
What’s remarkable about MobX is its flexibility. We can handle our application state independently of any UI framework. This makes the code more modular, portable, and easy to test. So, in a nutshell, MobX helps us manage state effortlessly and efficiently in your applications.
MobX is versatile — it works in various environments, like browsers and Node.js projects that support ES5.
Regarding React bindings for MobX, you can choose between mobx-react-lite
for functional components or mobx-react
for functional and class-based components. To install MobX, simply add the correct binding to your Yarn or npm command based on your needs:
npm install --save mobx npm install --save mobx-react-lite # or, npm install --save mobx-react (based on your preference)
Let’s work with another React app, this time to create a timer instead of a counter. Paste this code inside a React component:
import { makeAutoObservable } from 'mobx'; import { observer } from 'mobx-react-lite'; function createTimer() { return makeAutoObservable({ secondsPassed: 0, increase() { this.secondsPassed += 1; }, reset() { this.secondsPassed = 0; }, }); } const myTimer = createTimer(); const TimerView = observer( ({ timer, }: { timer: { secondsPassed: number; increase(): void; reset(): void }; }) => ( <button onClick={() => timer.reset()}> Seconds passed: {timer.secondsPassed} </button> ) ); export const App = () => <TimerView timer={myTimer} />; setInterval(() => { myTimer.increase(); }, 1000);
You can view and interact with the example in this StackBlitz repo.
When we wrap the TimerView
React component with the observer
, it understands that it needs to update whenever the timer.secondsPassed
changes, even if we don’t explicitly mention it. Thanks to the reactivity system in MobX, our component will automatically re-render whenever that specific field is updated.
So, whenever we click on the button or use setInterval
, it triggers an action — Timer.increase
or myTimer.reset
— updating the observable state, or myTimer.secondsPassed
. This update then propagates smoothly to all the computations and side effects like TimerView
that rely on those changes:
Although we focused on the React app in this article, MobX is supported by some other view libraries as well. For more details, you can visit the MobX documentation.
NgRx is a store management solution for Angular. It’s an RxJS-based global state management solution inspired by Redux that helps you handle the application’s state in a way that makes it easy to maintain and understand. Using a single state and actions lets you clearly express how your application’s state changes over time.
Some notable features of the NgRx Store include:
To install the NgRx Store in your project, you need to enter the following command in your terminal:
ng add @ngrx/store@latest
Let’s create a new Angular project using Angular CLI. Please check the Angular documentation for help if you are new to Angular or Angular CLI. After initializing a new Angular project and installing our @ngrx/store
library, we can proceed to the next stage.
Note that we’re implementing our simple NgRx state management example using Angular 17, which differs significantly from previous versions. Also, this tutorial differs slightly from the original documentation. If you are using an older Angular version, you should follow the appropriate documentation.
Let’s work on another counting example. Let’s create a new file, counter.actions.ts
, in the src/app/
directory and paste the following code:
import { createAction } from '@ngrx/store'; export const increment = createAction('[Counter Component] Increment'); export const decrement = createAction('[Counter Component] Decrement'); export const reset = createAction('[Counter Component] Reset');
The above code describes unique events that are dispatched from components and services. Next, we’ll define a reducer function — src/app/counter.reducer.ts
— to handle changes in the counter value based on the provided actions:
import { createReducer, on } from '@ngrx/store'; import { increment, decrement, reset } from './counter.actions'; export const initialState = 0; export const counterReducer = createReducer( initialState, on(increment, (state) => state + 1), on(decrement, (state) => state - 1), on(reset, (state) => 0) );
Now, let’s add the counterReducer
in the app.config.ts
:
import { ApplicationConfig } from '@angular/core'; import { provideStore } from '@ngrx/store'; import { counterReducer } from './counter.reducer'; export const appConfig: ApplicationConfig = { providers: [provideStore({ count: counterReducer })], };
Let’s create a new component called my-counter
using the following command:
ng g c my-counter
This command should create all the files we need, including the my-counter.component.ts
file and the my-counter.component.html
file. Copy the following code into the my-counter.component.ts
file:
import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { increment, decrement, reset } from '../counter.actions'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-my-counter', templateUrl: './my-counter.component.html', standalone: true, imports: [CommonModule], }) export class MyCounterComponent { count$: Observable<number>; constructor(private store: Store<{ count: number }>) { this.count$ = store.select('count'); } increment() { this.store.dispatch(increment()); } decrement() { this.store.dispatch(decrement()); } reset() { this.store.dispatch(reset()); } }
Next, let’s copy the following code in the my-counter.component.html
file:
<button (click)="increment()">Increment</button> <div>Current Count: {{ count$ | async }}</div> <button (click)="decrement()">Decrement</button> <button (click)="reset()">Reset Counter</button>
Now, let’s add this new component in AppComponent
. Paste the following code in your app.component.ts
file:
import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { MyCounterComponent } from './my-counter/my-counter.component'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule, RouterOutlet, MyCounterComponent], templateUrl: './app.component.html', styleUrl: './app.component.css' }) export class AppComponent { title = 'angular-project'; }
Finally, declare it in the app template:
<h1>Hello World!</h1> <app-my-counter></app-my-counter>
The code above sets up a simple counter in an Angular application using the NgRx Store for state management.
For more details, you can go through the NgRx documentation.
Pinia is the official state management library for Vue. It enables seamless state-sharing among components and pages. While the Composition API allows us to share global states easily in SPAs, it might expose our app to security vulnerabilities if it’s rendered on the server side, making Pinia crucial.
Even in smaller applications, Pinia brings several advantages, including:
Pinia also offers plugins to extend its features, robust TypeScript support, and compatibility with server-side rendering.
To use Pinia, you must first install it using your favorite package manager:
yarn add pinia # or with npm npm install pinia
Then, create a Pinia instance — the root store — and pass it to the app as a plugin:
import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const pinia = createPinia() const app = createApp(App) app.use(pinia) app.mount('#app')
If you are using Vue 2, you also need to install a plugin and inject the created Pinia at the root of the app:
import { createPinia, PiniaVuePlugin } from 'pinia' Vue.use(PiniaVuePlugin) const pinia = createPinia() new Vue({ el: '#app', pinia, })
This will also add support for Devtools. While Vue 3 currently lacks certain features like time traveling and editing due to Vue Devtools limitations, the overall developer experience is significantly enhanced with many Devtools features.
Let’s keep working on the counting example. Create a file called src/stores/counter.ts
and paste in the following code:
// src/stores/counter.ts import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), actions: { increment() { this.count++ }, }, })
After that, we can use it in a component as shown below:
<script setup> import { useCounterStore } from './stores/counter'; const counter = useCounterStore(); </script> <template> <div>Current Count: {{ counter.count }}</div> <button @click="counter.increment()">Increment</button> </template>
As we can see, we are getting the state info from useCounterStore()
. From that, we can show the state value through the component and also modify the state values using the store actions.
You can check the complete StackBlitz repo to see and interact with the demo. For more details, please visit the Pinia documentation.
Recoil is another state management library for React, built by Meta. It lets you create a data-flow graph where you flow your shared states (atoms) through functions (selectors) and eventually reach your React components.
Some notable Recoil features include:
Installing Recoil is pretty straightforward, just like the other packages. We can use any of our favorite package managers to install the state management library in our React app:
npm install recoil # or using yarn yarn add recoil # or using bower bower install --save recoil
RecoilRoot
Components with states that are managed by Recoil need the RecoilRoot
component to appear somewhere in the parent tree. The RecoilRoot
component is essential for creating the context for Recoil state management. Without adding this, the Recoil state management will not work.
An excellent place to put this component is in your root component, like so:
import { atom, RecoilRoot, selector, useRecoilState, useRecoilValue, } from 'recoil'; export const App = () => { return ( <RecoilRoot> <CharacterCounter /> </RecoilRoot> ); };
In this example, we are building a character-counter example. We will take the text input and show the character length as output. We’ll implement the CharacterCounter
component in the following section.
An atom represents a piece of state. Atoms can be read and written from any component. Components that read the value of an atom are implicitly subscribed to that atom, so any atom updates will result in a re-render of all components subscribed to that atom:
const textState = atom({ key: 'textState', default: '', });
Usually in React, we use the useState
Hook to read from and write to the local state of a component. But with Recoil, to read from and write to an atom, we need to use useRecoilState()
like this:
const [text, setText] = useRecoilState(textState);
We can read from the text
variable and write using the setText
function. So, components that need to read from and write to an atom should use useRecoilState()
as shown below:
const TextInput = () => { const [text, setText] = useRecoilState(textState); const onChange = (event) => { setText(event.target.value); }; return ( <div> <input type="text" value={text} onChange={onChange} /> <br /> Echo: {text} </div> ); }; const CharacterCounter = () => { return ( <div> <TextInput /> <CharacterCount /> </div> ); };
A selector represents a piece of derived state, which is a transformation of a state that gets computed based on other states and can change when those states change. We can think of the derived state as the output of passing a state to a pure function that modifies the given state in some way:
const charCountState = selector({ key: 'charCountState', get: ({ get }) => { const text = get(textState); return text.length; }, });
The code above determines the length of the textState
value. We can use the useRecoilValue()
Hook to read the value of charCountState
and show the character count of the input value in our component:
const CharacterCount = () => { const count = useRecoilValue(charCountState); return <>Character Count: {count}</>; };
You can find the full code for this example in this StackBlitz repo. For more details, you can visit the Recoil documentation.
React Query — officially called TanStack Query — is an asynchronous state management library for React and React Native. It can handle tasks like data fetching, caching, synchronizing, and updating server state in your React apps.
While other state management solutions provide a way to view and update states across the application, React Query provides solutions to manage the data that we get from the API.
We can install React Query by running the following command:
npm i @tanstack/react-query # or yarn add @tanstack/react-query
We can fetch the data from an API using the useQuery
Hook. Similarly, we can request any POST, PUT, PATCH, or DELETE operation using the useMutation
Hook. Here is a simple example in the React App.tsx
file, where we will fetch some todo data from a fake API:
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; const queryClient = new QueryClient() const API_ENDPOINT = `https://jsonplaceholder.typicode.com`; const Todo = () => { const { data } = useQuery({ queryKey: [`${API_ENDPOINT}/todos`], queryFn: () => fetch(`${API_ENDPOINT}/todos`).then((res) => res.json()), }); console.log(data); return ( <div> {(data || []).map((item) => ( <div key={item.id}> <h4 style={{ lineHeight: '100%', marginBottom: 0 }}>{item.title}</h4> <p>{item.completed ? 'Done' : 'Not Done Yet'}</p> </div> ))} </div> ); }; export const App = () => { return ( <QueryClientProvider client={queryClient}> <Todo /> </QueryClientProvider> ); };
The complete code is available in this StackBlitz repo. To learn more, you can go through the React Query documentation.
Jotai is another state management library for React. Like Recoil, it takes an atomic approach to global React state management.
We can create a state by putting together “atoms,” or pieces of state. The renders automatically get smarter based on what these atoms depend on.
This clever trick solves the problem of unnecessary re-renders in the React context, eliminating the need for memoization. It gives developers a smooth experience, similar to using signals while sticking to a clear and straightforward programming style, making it scalable.
To install Jotai in your project, simply use one of the commands below:
# npm npm i jotai # yarn yarn add jotai # pnpm pnpm add jotai
Adding the optional SWC or Babel plugin is recommended to enable React Fast Refresh support for the best developer experience specific to each framework. Let’s see some popular configuration options.
If you want to add the SWC plugin to a Next.js project, do the following:
# npm npm install --save-dev @swc-jotai/react-refresh # next.config.js experimental: { swcPlugins: [['@swc-jotai/react-refresh', {}]], }
Meanwhile, you can add the Babel plugin to your Next.js project like so:
># .babelrc { "presets": ["next/babel"], "plugins": ["jotai/babel/plugin-react-refresh"] }
For Vite, you can add the SWC plugin to a React project with the code below:
# npm npm install --save-dev @swc-jotai/react-refresh # vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; export default defineConfig({ plugins: [ react({ plugins: [['@swc-jotai/react-refresh', {}]], }), ], });
For Gatsby, you can add the Babel plugin with the code below:
# npm npm install --save-dev babel-preset-gatsby # .babelrc { "presets": ["babel-preset-gatsby"], "plugins": ["jotai/babel/plugin-react-refresh"] } # gatsby-config.js flags: { DEV_SSR: false, }
Here is a basic example of state management in Next.js with Jotai, where we will manage our state taken from the input, transform its value to uppercase value, and show it to another component.
Let’s first add our atoms in src/lib/atom.ts
file:
import { atom } from 'jotai'; export const textAtom = atom('hello'); export const uppercaseAtom = atom((get) => get(textAtom).toUpperCase());
Now, let’s add these two components in our src/components
folder: Input
component and Uppercase
component:
// src/components/Input.tsx 'use client'; import { textAtom } from '@/lib/atoms'; import { useAtom } from 'jotai'; export const Input = () => { const [text, setText] = useAtom(textAtom); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value); return <input className="border" value={text} onChange={handleChange} />; }; // src/components/Uppercase.tsx 'use client'; import { uppercaseAtom } from '@/lib/atoms'; import { useAtom } from 'jotai'; export const Uppercase = () => { const [uppercase] = useAtom(uppercaseAtom); return <div>Uppercase: {uppercase}</div>; };
Now, let’s add these two components to our homepage via the src/app/page.tsx
component file:
import { Input } from '@/components/Input'; import { Uppercase } from '@/components/Uppercase'; const Home = () => { return ( <> <Input /> <Uppercase /> </> ); }; export default Home;
The complete code is available in this StackBlitz repo. For more information, you can go through the Jotai documentation.
While choosing state management libraries for your TypeScript projects, you should consider the following:
With that in mind, here is a comparison table of the solutions we reviewed in this article to help you make your choice:
Tool | Library/framework support | Popularity | Documentation | Community support | Ease of use/DX |
---|---|---|---|---|---|
Redux | Almost all JS / TS view libraries | 60.2K stars on GitHub. Popular among almost all professional React developers | Good | Extensive, large, and growing community support | Easy |
MobX | Almost all JS / TS view libraries | 27K stars on GitHub. Known by some professional and expert developers; less popular than Redux | Good | Has smaller community support than Redux | Hard |
NgRx | Angular | 7.8K stars on GitHub. Popular among almost all professional Angular developers | The Angular version is usually updated frequently, but the documentation can be outdated at times | Extensive, large, and growing community support | Easy |
Pinia | Vue | 12K stars on GitHub. Popular among almost all professional Vue developers | Good | Community support is growing | Easy |
Recoil | React | 19.3K stars on GitHub. Known by some professional React developers; less popular than Redux | Good | Community support is growing | Easy |
React Query (TanStack Query) | React | 38.2K stars on GitHub. Used by many professional React developers | Good | Extensive, large, and growing community support | Easy |
Jotai | React | 16.5K stars on GitHub. Relatively less popular | Good | Community support is growing | Easy |
Here are a few resources you can check out for more in-depth comparisons:
In this article, we’ve seen many state management libraries that we can use in any of our TypeScript projects. Our choice of state management solution depends on considerations such as the project library, performance considerations, developer experience, community support, and more.
Generally speaking, state management libraries ease our lives by making our projects scalable, faster, and more robust. State management solutions with TypeScript support improve developer support and minimize bugs and typing errors.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
One Reply to "Comparing TypeScript state management solutions"
what about Zustand?