Master Redux-Persist & React Router v7: The 2025 Guide
Master the art of state persistence in React. This 2025 guide shows you how to flawlessly combine Redux-Persist with React Router v7 for a seamless UX.
Elena Petrova
Senior Frontend Engineer specializing in React state management and application architecture.
Ever built a slick React application, only to watch your user’s beautifully crafted state vanish into the digital ether on a simple page refresh? It’s a classic frustration. You’ve got your components rendering perfectly with React, your state managed cleanly with Redux Toolkit, and your navigation handled elegantly by React Router. Yet, the moment a user hits F5 or closes a tab, it’s as if they were never there. All their settings, their cart, their session data—gone.
This is where the real magic of modern web development comes in. It’s not just about building features; it’s about creating a seamless, persistent experience that feels robust and reliable. In this guide, we’re diving deep into the definitive solution for 2025: the powerful combination of Redux-Persist and React Router v7. We'll go beyond basic setup and explore how to architect an application that gracefully handles state rehydration *before* your routes even think about rendering.
Forget the annoying flicker of being redirected to a login page only to be snapped back a moment later. We're going to build an app that just *works*, providing a user experience so smooth, they won't even notice the technical wizardry happening under the hood. Let's get started.
Why This Combo is the Gold Standard
In a single-page application (SPA), three libraries often form the core of the user experience:
- React: The declarative UI library for building what the user sees.
- Redux: The predictable state container for managing the application's global state (the "what").
- React Router: The library for handling client-side navigation (the "where").
Redux-Persist is the missing piece of the puzzle. It handles the "how long." It automatically saves a snapshot of your Redux store to a persistent storage engine (like `localStorage`) and rehydrates it when the app launches. This combination allows you to build applications that remember user preferences, authentication status, and other critical data across browser sessions.
Step 1: Setting Up a Modern React Project
For a 2025-ready project, we'll use Vite and TypeScript. They provide a fast development experience and type safety, which are essential for scalable applications.
# Create a new React + TypeScript project with Vite
npm create vite@latest my-persistent-app -- --template react-ts
# Navigate into the project directory
cd my-persistent-app
# Install the necessary libraries
npm install @reduxjs/toolkit react-redux react-router-dom redux-persist
Step 2: Integrating Redux Toolkit
Redux Toolkit (RTK) is the standard for writing Redux logic today. Let's create a simple user slice and configure our store.
Create a User Slice
Create a file at `src/features/userSlice.ts`:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UserState {
token: string | null;
profile: { name: string } | null;
}
const initialState: UserState = {
token: null,
profile: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
loginSuccess: (state, action: PayloadAction<{ token: string; profile: { name: string } }>) => {
state.token = action.payload.token;
state.profile = action.payload.profile;
},
logout: (state) => {
state.token = null;
state.profile = null;
},
},
});
export const { loginSuccess, logout } = userSlice.actions;
export default userSlice.reducer;
Configure the Store
Now, create `src/app/store.ts`:
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Step 3: Adding Persistence with Redux-Persist
Now, let's add `redux-persist` to save our user state. We'll modify our `store.ts`.
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import userReducer from '../features/userSlice';
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // defaults to localStorage for web
const persistConfig = {
key: 'root',
version: 1,
storage,
whitelist: ['user'] // Only persist the 'user' slice
};
const rootReducer = combineReducers({ user: userReducer });
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export let persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Notice a few key changes: We use `persistReducer` to wrap our `rootReducer`. We also configure the middleware to ignore actions from `redux-persist` to avoid serialization errors. Most importantly, we use the `whitelist` property. This is crucial: you rarely want to persist your entire Redux state. Only persist what's necessary, like user authentication or theme settings.
Step 4: Routing with React Router v7
React Router v6 introduced data routers, and v7 is expected to refine this pattern. We'll use `createBrowserRouter` for this modern approach. Let's define a public home page and a protected profile page.
// In a new file, e.g., `src/router.tsx`
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from './app/store';
// Components (create these as simple placeholders)
import Home from './pages/Home';
import Profile from './pages/Profile';
import Login from './pages/Login';
const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
const { token } = useSelector((state: RootState) => state.user);
if (!token) {
// Redirect them to the /login page, but save the current location they were
// trying to go to. This allows us to send them along to that page after they login.
return <Navigate to="/login" replace />;
}
return children;
};
export const router = createBrowserRouter([
{
path: '/',
element: <Home />,
},
{
path: '/login',
element: <Login />,
},
{
path: '/profile',
element: (
<ProtectedRoute>
<Profile />
</ProtectedRoute>
),
},
]);
The Core Challenge: The Rehydration Race Condition
Here's the problem. Imagine a logged-in user refreshes the page on `/profile`. This happens:
- The app loads. The Redux store is initialized with its `initialState` (user token is `null`).
- React Router kicks in and tries to render the `/profile` route.
- Our `ProtectedRoute` component runs. It checks the Redux store, sees `token` is `null`, and redirects to `/login`.
- A moment later, Redux-Persist finishes reading from `localStorage` and rehydrates the store. The user token is now restored.
The user sees a jarring flicker from `/profile` to `/login`. This is a classic race condition. The router renders before the state is ready.
The Solution: Using `PersistGate` as the Guardian
Redux-Persist provides a brilliant and simple solution: the `PersistGate` component. It delays rendering its children until the store has been rehydrated.
Let's update our main entry point, `src/main.tsx`, to tie everything together correctly.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { RouterProvider } from 'react-router-dom';
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from './app/store';
import { router } from './router';
// A simple loading component
const Loading = () => <div>Loading...</div>;
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<PersistGate loading={<Loading />} persistor={persistor}>
<RouterProvider router={router} />
</PersistGate>
</Provider>
</React.StrictMode>
);
Look at the nesting order. It's critical:
<Provider>
makes the Redux store available.<PersistGate>
sits inside. It subscribes to the store and waits for rehydration. While waiting, it renders its `loading` prop (a spinner, a splash screen, or even `null`).<RouterProvider>
is a child of `PersistGate`. This means our entire router and all its components will not render until the persisted state is ready.
The race condition is solved. When `ProtectedRoute` runs, it's guaranteed to have the fully rehydrated state from the previous session.
Advanced Patterns & Best Practices
With the core architecture in place, you can handle more complex scenarios.
Storage Engine Choices
While `localStorage` is the default, it's not your only option.
Engine | Use Case | Persistence |
---|---|---|
storage (localStorage) |
Default for web. Good for non-sensitive data you want to keep indefinitely. | Persists until cleared by user/app. |
storage/session (sessionStorage) |
Data you want to keep only for the duration of the browser tab session. | Cleared when the tab is closed. |
@react-native-async-storage/async-storage |
The standard for React Native applications. | Persists on the device. |
Handling State Migrations
What happens if you change the shape of your persisted state (e.g., rename a field in your `user` slice)? `redux-persist` includes a `createMigrate` function to handle this, allowing you to run transformation functions on old state versions to bring them up to date. This is an advanced but vital feature for long-lived applications.
Conclusion: Building Resilient Apps
Combining Redux-Persist and React Router isn't just a matter of installing two libraries. The key to a professional, flicker-free user experience lies in controlling the render order. By wrapping your router with `PersistGate`, you ensure that your application's routing logic always operates on fully rehydrated, session-aware state.
This pattern—using `PersistGate` as a guardian for your router—is the definitive way to build resilient, stateful SPAs in 2025. It transforms your app from a forgetful tool into a reliable companion that remembers your users, creating the seamless experience they expect from a modern web application.