React

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.

E

Elena Petrova

Senior Frontend Engineer specializing in React state management and application architecture.

8 min read40 views

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`:

Advertisement
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:

  1. The app loads. The Redux store is initialized with its `initialState` (user token is `null`).
  2. React Router kicks in and tries to render the `/profile` route.
  3. Our `ProtectedRoute` component runs. It checks the Redux store, sees `token` is `null`, and redirects to `/login`.
  4. 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:

  1. <Provider> makes the Redux store available.
  2. <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`).
  3. <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.

Tags

You May Also Like