Building a modern health dashboard in React requires careful consideration of state management. You're not just dealing with simple UI toggles; you're handling sensitive user data, complex data structures from wearables, and the critical need for features like offline access. Choosing the right state management library is a foundational decision that will impact your app's performance, scalability, and maintainability.
In this article, we'll dive into a technical comparison of two of the most popular state management solutions in the React ecosystem: Zustand and Redux Toolkit (RTK). We'll analyze them through the lens of building a health dashboard, focusing on:
- Boilerplate and Developer Experience: How quickly can you get up and running?
- Managing Complex Health Data: How do they handle normalized and relational data?
- Performance: Which is better for real-time updates from health devices?
- Offline Synchronization: How can we implement an "offline-first" approach for our health app?
By the end, you'll have a clear understanding of the strengths and weaknesses of each library, complete with practical code examples, to help you make an informed decision for your next health tech project.
Understanding the Problem: State Management in a Health App
A health dashboard presents unique state management challenges:
- Complex, Normalized Data: We need to manage user profiles, a collection of connected devices, and time-series data for various health metrics (heart rate, steps, sleep, etc.).
- Real-time Updates: The app might need to handle frequent updates from a connected wearable via WebSockets or similar technology.
- Offline Functionality: A user should be able to input data or view their existing data even without an internet connection. The app must sync seamlessly when it comes back online.
- Performance: With potentially large datasets and real-time updates, we need to avoid unnecessary re-renders to keep the UI smooth and responsive.
Prerequisites
- A solid understanding of React and hooks.
- Node.js and npm/yarn installed.
- Familiarity with basic state management concepts in React.
Let's set up a new React project to build our examples:
npx create-react-app health-app --template typescript
cd health-app
Now, let's look at how Zustand and Redux Toolkit tackle these challenges.
Step 1: Boilerplate and Initial Setup
What we're doing
We'll create a simple store with both libraries to manage a user's profile information. This will give us a first look at the difference in boilerplate and setup.
Zustand: Minimalist and Hook-Based
Zustand is known for its simplicity and minimal API. You can create a global store with just a few lines of code.
Installation:
npm install zustand
Creating the user store (src/stores/userStore.ts):
// src/stores/userStore.ts
import { create } from 'zustand';
interface UserProfile {
name: string;
age: number;
email: string;
}
interface UserState {
profile: UserProfile | null;
setUserProfile: (profile: UserProfile) => void;
clearUserProfile: () => void;
}
export const useUserStore = create<UserState>((set) => ({
profile: null,
setUserProfile: (profile) => set({ profile }),
clearUserProfile: () => set({ profile: null }),
}));
Redux Toolkit: Structured and Opinionated
Redux Toolkit (RTK) is the official, recommended way to write Redux logic. It simplifies the traditionally verbose Redux setup with tools like configureStore and createSlice.
Installation:
npm install @reduxjs/toolkit react-redux
Creating the user slice (src/features/user/userSlice.ts):
// src/features/user/userSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';
interface UserProfile {
name: string;
age: number;
email: string;
}
interface UserState {
profile: UserProfile | null;
}
const initialState: UserState = {
profile: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUserProfile: (state, action: PayloadAction<UserProfile>) => {
state.profile = action.payload;
},
clearUserProfile: (state) => {
state.profile = null;
},
},
});
export const { setUserProfile, clearUserProfile } = userSlice.actions;
export const selectUserProfile = (state: RootState) => state.user.profile;
export default userSlice.reducer;
Configuring the store (src/app/store.ts):
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/user/userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
How it works
- Zustand: Creates a custom hook (
useUserStore) directly. It doesn't require a context provider to wrap your application. State updates are done via thesetfunction provided in thecreatecallback. - Redux Toolkit: Uses
createSliceto generate actions and a reducer for a specific piece of state. These reducers are then combined in a single global store usingconfigureStore. Your application needs to be wrapped in a<Provider>component.
Verdict on Boilerplate
Zustand is the clear winner in terms of minimal boilerplate. The setup is faster and requires fewer files and concepts to get started. Redux Toolkit, while much improved over classic Redux, still has more "ceremony" with its slice and store configuration.
Step 2: Managing Complex Data (Health Metrics)
What we're doing
Our health app needs to manage a collection of health metrics, which can be thought of as a normalized dataset. We'll see how both libraries handle this common scenario.
Redux Toolkit: createEntityAdapter for Normalization
RTK shines when it comes to managing normalized data. createEntityAdapter is a powerful utility that provides pre-built reducers and selectors for CRUD operations on a normalized state structure.
Creating the metrics slice (src/features/metrics/metricsSlice.ts):
// src/features/metrics/metricsSlice.ts
import {
createSlice,
createEntityAdapter,
PayloadAction,
} from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';
export interface HealthMetric {
id: string; // e.g., 'heart-rate-2023-12-25T10:00:00Z'
type: 'heartRate' | 'steps' | 'sleep';
value: number;
timestamp: string;
}
const metricsAdapter = createEntityAdapter<HealthMetric>({
sortComparer: (a, b) => b.timestamp.localeCompare(a.timestamp),
});
const initialState = metricsAdapter.getInitialState({
status: 'idle',
error: null as string | null,
});
const metricsSlice = createSlice({
name: 'metrics',
initialState,
reducers: {
addMetric: metricsAdapter.addOne,
addMetrics: metricsAdapter.addMany,
updateMetric: metricsAdapter.updateOne,
},
});
export const { addMetric, addMetrics, updateMetric } = metricsSlice.actions;
export const {
selectAll: selectAllMetrics,
selectById: selectMetricById,
} = metricsAdapter.getSelectors((state: RootState) => state.metrics);
export default metricsSlice.reducer;
Zustand: Using Slices for Modularity
While Zustand doesn't have a built-in equivalent to createEntityAdapter, you can achieve a similar level of organization by using the "slice pattern". This involves creating separate slices of state and combining them into a single store.
Creating a metrics store with slices (src/stores/rootStore.ts):
// src/stores/rootStore.ts
import { create } from 'zustand';
import { UserState, useUserStore } from './userStore';
export interface HealthMetric {
id: string;
type: 'heartRate' | 'steps' | 'sleep';
value: number;
timestamp: string;
}
interface MetricsState {
metrics: Record<string, HealthMetric>;
addMetric: (metric: HealthMetric) => void;
// ... other metric actions
}
const createMetricsSlice = (set: any) => ({
metrics: {},
addMetric: (metric: HealthMetric) =>
set((state: { metrics: { metrics: any; }; }) => ({
metrics: {
...state.metrics.metrics,
[metric.id]: metric,
},
})),
});
// We can combine slices, although for this example we'll keep them separate
// to show how Zustand encourages modularity.
// const useRootStore = create((...a) => ({
// ...useUserStore(...a),
// ...createMetricsSlice(...a),
// }));
// For simplicity, we'll create a separate metrics store
export const useMetricsStore = create<MetricsState>(createMetricsSlice);
How it works
- Redux Toolkit:
createEntityAdapterautomatically generates the state shape ({ ids: [], entities: {} }) and reducers likeaddOne,addMany,updateOne, which simplifies working with collections of data. - Zustand: You have to define the state shape and update logic yourself. While more manual, it gives you complete flexibility. The slice pattern helps keep your store organized as it grows.
Verdict on Complex Data
Redux Toolkit has a clear advantage here for highly structured, relational data. createEntityAdapter enforces best practices for data normalization and saves you from writing repetitive CRUD logic. Zustand is perfectly capable, but requires you to build this logic yourself.
Step 3: Implementing Offline Sync
A key feature for a health app is the ability to work offline. Both libraries can achieve this with middleware that persists the store's state to local storage.
Zustand: persist Middleware
Zustand offers a clean and simple persist middleware.
Updating the user store for persistence (src/stores/userStore.ts):
// src/stores/userStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
// ... (interfaces remain the same)
export const useUserStore = create(
persist<UserState>(
(set) => ({
profile: null,
setUserProfile: (profile) => set({ profile }),
clearUserProfile: () => set({ profile: null }),
}),
{
name: 'user-storage', // unique name
storage: createJSONStorage(() => localStorage), // (optional) default: localStorage
}
)
);
Redux Toolkit: redux-persist
For Redux Toolkit, the most common solution is the redux-persist library.
Installation:
npm install redux-persist
Updating the store configuration (src/app/store.ts):
// src/app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // defaults to localStorage for web
import userReducer from '../features/user/userSlice';
import metricsReducer from '../features/metrics/metricsSlice';
const persistConfig = {
key: 'root',
storage,
whitelist: ['user'], // only persist the user slice
};
const rootReducer = combineReducers({
user: userReducer,
metrics: metricsReducer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false, // Recommended to disable for redux-persist
}),
});
export const persistor = persistStore(store);
// ... (RootState and AppDispatch types)```
**Updating the root component (`src/index.tsx`):**
```tsx
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { store, persistor } from './app/store';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<App />
</PersistGate>
</Provider>
</React.StrictMode>
);
Verdict on Offline Sync
Zustand's built-in persist middleware is more straightforward to implement. It requires less configuration and no changes to your application's root component. redux-persist is more powerful, offering more advanced options like migrations and transforms, but it also adds more complexity to your setup.
Performance Considerations
- Bundle Size: Zustand is significantly smaller than Redux Toolkit, which can be a factor in performance-critical applications.
- Re-renders: Zustand's hook-based selectors make it easy to subscribe to specific parts of the state, which can prevent unnecessary re-renders by default. In Redux, while
useSelectoris powerful, it's up to the developer to ensure they are selecting the minimal state necessary to avoid extra renders. Libraries likereselectare often used with Redux to create memoized selectors for performance optimization.
For a health dashboard with potentially high-frequency updates (e.g., from a real-time heart rate monitor), Zustand's performance characteristics might give it a slight edge due to its fine-grained subscription model.
Conclusion: Which One Should You Choose?
Both Zustand and Redux Toolkit are excellent, mature libraries for state management in React. The best choice depends on the specific needs of your project and team.
Choose Zustand if:
- You are working on a small to medium-sized project.
- You prioritize simplicity, speed of development, and minimal boilerplate.
- You want a lightweight solution with a small bundle size.
- Your team prefers a more flexible, less opinionated approach.
Choose Redux Toolkit if:
- You are building a large-scale, enterprise-level application.
- Your team needs a predictable, structured approach to state management.
- You need powerful developer tools, like the Redux DevTools, for time-travel debugging.
- You have a lot of complex, normalized data that would benefit from
createEntityAdapter.
For our hypothetical health dashboard, if you're a startup building an MVP, Zustand would be an excellent choice to get up and running quickly. If you're building a large, complex health platform with multiple teams, the structure and predictability of Redux Toolkit would be more beneficial in the long run.
Resources
- Zustand:
- Redux Toolkit: