Persisting state on page refresh in React/Redux app
mihomihouk
Posted on July 10, 2023
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}</>;
};
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');
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}</>;
};
And finally, clear the stored authentication status on logout.
sessionStorage.removeItem('isAuthenticated');
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
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>;
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>;
Let me briefly explain a few important points:
- storage
const persistConfig = {
key: 'root',
storage,
stateReconciler: autoMergeLevel2
};
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>
);
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.
Posted on July 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.