Deep Dive: Handling Session State in React Applications

Pedro Laracuente
reactstate-managementauthentication

In modern web applications, managing session state efficiently is critical for ensuring a secure and consistent user experience. In Rica's Plants, I implemented a robust session management solution that leverages the power of React's Context API and browser local storage to persist user authentication across navigations and browser reloads.


Centralizing Session State with React Context

At the core of our session management is a global context created in `src/contexts/SessionContext.js`. This simple context lets our components share the session state without resorting to messy prop drilling. The session context is then provided by the main application in `src/App.jsx`, where we initialize our session token from local storage and expose helper methods for signing in and out.

Consider the following snippet from `src/App.jsx`:

// ... import statements

const App = () => {
  // Initialize session token from local storage
  const [sessionToken, setSessionToken] = useState(() =>
    userService.getSessionTokenStorage()
  );

  return (
    <SessionContext.Provider
      value={{
        // Decode the JWT token to get the username, or null if no session exists
        username: sessionToken ? jwtDecode(sessionToken).username : null,
        // Method to sign in: update state and persist token in local storage
        signIn: (token) => {
          setSessionToken(token);
          userService.setSessionTokenStorage(token);
        },
        // Method to sign out: clear session state and remove token from local storage
        signOut: () => {
          setSessionToken(null);
          userService.removeSessionTokenStorage();
        },
      }}
    >
      <BrowserRouter>
        <ScrollToTop />
        <Routes>
          <Route path="/" element={<SignInPage />} />
          <Route path="/sign-up" element={<SignUpPage />} />
          <Route path="/plants" element={<PlantListPage />} />
          <Route path="/plants/:plantId" element={<PlantShowPage />} />
        </Routes>
      </BrowserRouter>
    </SessionContext.Provider>
  );
};

export default App;

In this snippet, the session token is retrieved from local storage upon initialization. If a token exists, we extract the username from the JWT using the `jwt-decode` library. Defining `signIn` and `signOut` methods allows our authentication pages to update session state consistently, ensuring that the rest of the application always has access to up-to-date user information.


Persisting Sessions with Local Storage

To allow session persistence even when the user refreshes the page or closes and reopens the browser, we store the session token in local storage. Our service layer encapsulates these operations, keeping the details abstracted from the rest of the application. Below is the relevant code from `src/services/user.js`:

const CAPSTONE_SESSION_TOKEN_KEY = "capstone_session_token";

export const setSessionTokenStorage = (capstoneSessionToken) =>
  localStorage.setItem(CAPSTONE_SESSION_TOKEN_KEY, capstoneSessionToken);

export const getSessionTokenStorage = () =>
  localStorage.getItem(CAPSTONE_SESSION_TOKEN_KEY);

export const removeSessionTokenStorage = () =>
  localStorage.removeItem(CAPSTONE_SESSION_TOKEN_KEY);

By wrapping local storage interactions in our user service, we maintain a clean separation of concerns. Components and contexts simply call these helper functions, unaware of the underlying implementation.


Integrating Session State with API Requests

Beyond user interface concerns, session state is also critical for securing backend interactions. Every API call made by our application includes the session token as part of the request headers. That way, the backend can verify the user's identity and permissions.

In our `src/services/apiFetch.js` utility, we augment all outgoing requests with the session token:

import * as userService from 'services/user';
const { VITE_API_BASE_URL} = import.meta.env;

  if (sessionToken) {
    const sessionToken = userService.getSessionTokenStorage();
  }

const apiFetch = (method, path, body) => {
  const options = {
    method,
    credentials: 'include',
    headers: {
      authorization: 'Bearer ' + sessionToken || '',
      'Content-Type': 'Application/json'
    }
  };


  if (body) {
    options.body = JSON.stringify(body);
  }
  return fetch(VITE_API_BASE_URL + path, options);
};

export default apiFetch;

This design ensures that every API request is automatically authenticated. If no session token is present, the backend can immediately reject unauthorized requests, adding an extra layer of security.


Enforcing Route Protection Using Session State

Another important aspect of session management is controlling access to various routes. For example, components like `RedirectToPlantsIfSignedIn` and `RedirectToSignInIfSignedOut`—located in the shared components—leverage the SessionContext to determine whether a user should be redirected away from or toward a particular page.

By centralizing this logic within our context, we can enforce route protection consistently across the entire application.


Conclusion

The session management solution in Rica's Plants is built on the solid foundations of React's Context API and browser local storage. By combining these technologies, the project ensures that user authentication is persistent, secure, and easily accessible throughout the application. With helper functions abstracting storage details and middleware automatically injecting session information into API requests, this design provides a clear, maintainable approach to session state management—an essential consideration for any modern web application.