Persisting state on page refresh in React/Redux app

mihomihouk

mihomihouk

Posted on July 10, 2023

Persisting state on page refresh in React/Redux app

Introduction

Refreshing a page is a common behaviour for app users. However, when using Redux, the state stored in the Redux store is reset on page refresh. This can be problematic for apps with protected routes, as users may be redirected to the login page even if they were previously authenticated.

In this article, we will explore several solutions, including session/local storage and Redux Persist, as well as their limitations to create a more user-friendly experience!

Problem

Consider the following example of a protected route:

import { useLocation, Navigate } from 'react-router-dom';
import { useAppSelector } from '../hooks/hooks';
import React from 'react';
import { LOG_IN_PATH } from './routes';

interface SecureRouteProps {
  children?: React.ReactNode;
}
export const SecureRoute: React.FC<SecureRouteProps> = ({ children }) => {
  const location = useLocation();
  const { isAuthenticated } = useAppSelector((state) => state.auth);

  if (!isAuthenticated) {
    return <Navigate to={LOG_IN_PATH} state={{ from: location }} replace />;
  }

  return <>{children}</>;
};

Enter fullscreen mode Exit fullscreen mode

The problem occurs when the user refreshes the page, causing the isAuthenticated state to revert to its initial value, leading to an unnecessary redirect to the login page.

This can be very frustrating for users as they have already been signed in. How can we avoid this unnecessary redirection while ensuring our pages are protected from unauthenticated users?

Common solutions

There are several solutions to prevent this unnecessary redirection on page refresh. One approach involves using session/local storage in combination with Redux.

In this approach, we need to write some additional code to add, read and remove a certain item from sessionStorage or localStorage alongside updating Redux.

Use session storage + Redux

If I implement this in my project, the change would look like this:

Store the authentication status in session storage after successful login:

sessionStorage.setItem('isAuthenticated', 'true');
Enter fullscreen mode Exit fullscreen mode

In the protected route component, check the authentication status by getting the state stored in session storage. Then, if the user is not authenticated, redirect them to the login page:

export const SecureRoute: React.FC<SecureRouteProps> = ({ children }) => {
  const { pathname } = useLocation();
  const isAuthenticated = sessionStorage.getItem('isAuthenticated') === 'true';

  if (!isAuthenticated) {
    const redirect = encodeURIComponent(pathname);
    return (
      <Navigate
        to={{
          pathname: '/login',
          state: { from: redirect },
        }}
      />
    );
  }

  return <>{children}</>;
};
Enter fullscreen mode Exit fullscreen mode

And finally, clear the stored authentication status on logout.

sessionStorage.removeItem('isAuthenticated');
Enter fullscreen mode Exit fullscreen mode

Use local storage + Redux

The second approach is to use local storage and this is very similar to the first approach.

The code is almost identical. We just need to replace the sessionStorage in the example above with localStorage (e.g., const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';).

The major difference between using session storage and local storage is the duration of the state persistence. While the state stored in session storage gets reset once the tab closes, local storage allows the state to persist even if the user closes and reopens the browser.

These are already great solutions. However, one drawback of using session/local storage directly with Redux is that it requires adding additional code to every place where the state needs to be persisted. This can increase the complexity of the code and make it more susceptible to unexpected errors.

So I took a simpler and optimised approach: using Redux Persist.

Redux Persist

Redux Persist is a library designed to allow applications to preserve their state even on page refresh.

It is really easy to incorporate Redux Persist in our existing React/Redux app. The only thing we need to do is to make a small modification to the store and insert a wrapper provided by Redux Persist in a higher component. Doing so eliminates the need to add additional state updates in multiple pages/components.

How to integrate Redux Persist

Let's look at how I updated my code to use Redux Persist.

Install the Redux Persist library:

npm install redux-persist
Enter fullscreen mode Exit fullscreen mode

Update store config

Next, following their official document, I added some changes to my store.

Before:

import { PreloadedState, configureStore } from '@reduxjs/toolkit';
import modalReducer from '../slices/modals-slice';
import postReducer from '../slices/posts-slice';
import authReducer from '../slices/auth-slice';
import userProfileReducer from '../slices/user-profile-slice';

export const store = configureStore({
  reducer: {
    modals: modalReducer,
    posts: postReducer,
    auth: authReducer,
    userProfile: userProfileReducer
  }
});
export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return store;
};

export const createPreloadedState = (
  customState: Partial<RootState>
): PreloadedState<RootState> => {
  return {
    modals: { ...store.getState().modals, ...customState.modals },
    posts: { ...store.getState().posts, ...customState.posts },
    auth: { ...store.getState().auth, ...customState.auth },
    userProfile: { ...store.getState().userProfile, ...customState.userProfile }
  };
};
export type AppDispatch = typeof store.dispatch;
export type AppStore = ReturnType<typeof setupStore>;
export type RootState = ReturnType<typeof store.getState>;
Enter fullscreen mode Exit fullscreen mode

After:

import {
  PreloadedState,
  combineReducers,
  configureStore
} from '@reduxjs/toolkit';
import modalReducer from '../slices/modals-slice';
import postReducer from '../slices/posts-slice';
import authReducer from '../slices/auth-slice';
import userProfileReducer from '../slices/user-profile-slice';
import storage from 'redux-persist/lib/storage';
import { persistReducer, persistStore } from 'redux-persist';
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';

const persistConfig = {
  key: 'root',
  storage,
  stateReconciler: autoMergeLevel2
};

const rootReducer = combineReducers({
  modals: modalReducer,
  posts: postReducer,
  auth: authReducer,
  userProfile: userProfileReducer
});

const persistedReducer = persistReducer<ReturnType<typeof rootReducer>>(
  persistConfig,
  rootReducer
);

export const store = configureStore({
  reducer: persistedReducer
});

export const persistor = persistStore(store);

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return store;
};

export const createPreloadedState = (
  customState: Partial<RootState>
): PreloadedState<RootState> => {
  return {
    modals: { ...store.getState().modals, ...customState.modals },
    posts: { ...store.getState().posts, ...customState.posts },
    auth: { ...store.getState().auth, ...customState.auth },
    userProfile: { ...store.getState().userProfile, ...customState.userProfile }
  };
};
export type AppDispatch = typeof store.dispatch;
export type AppStore = ReturnType<typeof setupStore>;
export type RootState = ReturnType<typeof store.getState>;

Enter fullscreen mode Exit fullscreen mode

Let me briefly explain a few important points:

  • storage
const persistConfig = {
  key: 'root',
  storage,
  stateReconciler: autoMergeLevel2
};
Enter fullscreen mode Exit fullscreen mode

Here storage is shorthand for storage: storage, meaning I chose localStorage to store the persisted state. You could choose sessionStorage and write storage: storageSession instead.

  • stateReconciler

In the same object, we also see this somewhat unfamiliar property: stateReconciler. This is where we choose how to merge persisted state with the initial Redux state. This is set to autoMergeLevel1 by default but we can pass autoMergeLevel2 or hardSet as well. hardset completely overrides the initial state in our reducer. On the other hand, both autoMergeLevel1 and autoMergeLevel2 allow the initial state to be merged with the persisted state with slightly different behaviour: autoMergeLevel1 only concerns top-level property values while autoMergeLevel2 merges two levels deep.

To understand more about these differences, I highly recommend you to read this article.

Wrap root components with PersistGate

In order to ensure that UI gets rendered only after the persisted state has been retrieved and saved to Redux, we also need to add PersistGate to our root component.

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { persistor, store } from './store/store';
import App from './App';
import { disableReactDevTools } from '@fvilers/disable-react-devtools';
import { PersistGate } from 'redux-persist/integration/react';
import { MainLoader } from './components/main-loader';

if (process.env.NODE_ENV === 'production') disableReactDevTools();

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <PersistGate loading={<MainLoader />} persistor={persistor}>
        <BrowserRouter>
          <App />
        </BrowserRouter>
      </PersistGate>
    </Provider>
  </React.StrictMode>
);

Enter fullscreen mode Exit fullscreen mode

We could pass a customised loading component to the loading prop inside PersistGate.

This is it!
After these few changes, I could see refreshing a page no longer redirect users to the login page. The state still persists after closing the browser too, allowing us to access the app again without going through the login process.

Limitation

It's important to note that persisting sensitive data, such as access tokens or user IDs, in local or session storage can introduce security vulnerabilities, particularly cross-site scripting (XSS) attacks. These approaches might offer improved user experience by maintaining user state across page refreshes, but they can also expose sensitive information to malicious actors.

When sensitive data is stored in client-side storage, it becomes accessible to JavaScript code running in the user's browser. If an attacker manages to inject malicious code into your application through XSS, they can retrieve and misuse the stored data, compromising user security.

I will explore more secure approaches to this in my future article.

Conclusion

Redirecting users to the login page on every reload can lead to a poor user experience. This could be overcome by using session/local storage. Redux Persist simplifies this process by automatically persisting and rehydrating your Redux state.

However, it is very important to be aware of the security implications of storing data in local/session storage to protect users from malicious attacks.

💖 💪 🙅 🚩
mihomihouk
mihomihouk

Posted on July 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related