跪拜 Guibai
← Back to the summary

Five Gears That Hold a Login Session Together Without a Stateful Server

HTTP is clearly stateless, so how does the login state persist? — A full breakdown of React + Zustand + JWT authentication

Author: Asize Tags: Frontend, JavaScript

HTTP is clearly stateless, so how does the login state persist? — A full breakdown of React + Zustand + JWT authentication

In the last lesson (8.12), I set up the frontend API engineering with axios + mockjs, so the frontend could finally run requests without waiting for the backend. But that lesson left a gap: all the APIs were "naked," anyone could call them, and nobody asked "who are you?"

This lesson stitches that gap shut. The theme is just one thing — login authentication. The tech stack is React + react-router-dom + Zustand + axios + mockjs + jsonwebtoken, a complete authentication loop that the frontend can run entirely on its own.

1. HTTP is stateless, so when I refresh the page, "who I am" is lost

First, understand a key trait of HTTP: Stateless.

Stateless (stateless protocol): The HTTP protocol itself does not record "who sent the last request." Every request is a new face to the server; it won't assume this request is still you just because you logged in one second ago.

This statement sounds counterintuitive. I open a webpage, log in, then click to another page — I am clearly "still me," so how can HTTP not remember?

The answer is: HTTP never remembers. What remembers you is the browser, or rather, the "ID card" you secretly slip into every request.

So how do you "slip in the ID card"? There are actually two paths:

The teacher explicitly said that login now "generally uses JWT, JSON Web Token." Why has the cookie/session path fallen out of favor? I organized two reasons from my notes:

  1. Memory pressure: When there are many users, the server's memory has to store a huge number of session objects, and every sessionId must be matched correctly.
  2. Not suitable for distributed systems: A sessionId is a "drawer" in the server's memory. If a sessionId issued by this server is received by another server, that other server has no such drawer in its memory and cannot find the user. To go distributed, you have to move sessions to something like Redis for sharing, and the engineering complexity immediately spikes.

The JWT approach flips this — the server no longer remembers people; the client brings its own ID card. The server only guards a secret; any server that gets the token can verify the signature and decode the JSON object itself. It is naturally friendly to distributed deployment.

2. JWT is an encrypted ID card

2.1 Two actions: sign and verify

The notes compress all of JWT's actions into two words:

Salting (secret): JWT is not "encryption" in the cryptographic algorithm sense; more accurately, it is "signing." The server stamps the token with a secret that only it knows. Anyone who gets the token can read the payload (that part is base64), but cannot modify the payload — if modified, the signed signature will not match. The longer and more random the secret, the more secure.

2.2 cookie/session vs JWT: Understand with one diagram

There is a comparison in the notes; I organized it into a table:

Dimension cookie/session JWT
Where identity is stored Server memory Client (localStorage / cookie)
Who remembers the person Server Client brings its own
Distributed Unfriendly, sessionId cannot be found across servers Friendly, any server with the secret can verify
Server memory usage Large Almost none (only verifies signature, stores no state)

2.3 The sign scene: mock/user.js

The sign action on the server side is simulated with mock. The code is in mock/user.js:

import jwt from 'jsonwebtoken';

const secret = 'secret819!$';

export default [
    {
        url: '/api/login',// ? 
        method: 'POST',
        timeout: 2000,
        response: ({body })=>  {
            console.log(body);
            if(body.username !== 'admin' || body.password !== '123456'){
                return {
                    code:-1, // Error occurred
                    msg:'Incorrect username or password'
                }
            }
            // The server needs to issue a token to the user
            // The token needs to carry the user's information
            // jwt json format, web side (stateless), generate token
            // jwt generation has an encryption algorithm, the issued token needs to be salted (a security lock only you know) with a secret key
            const token = jwt.sign(
                {
                   user:body.username,
                   role:'user'
                },
                secret,
                {
                    expiresIn:'86400s'
                }
                );
            return {
                code:0, // No error occurred
                user:{
                    username:body.username
                },
                token:token
            }
        }
    },

Reading this code, I marked a few spots so I won't forget later:

2.4 The verify scene

/api/repo is a protected endpoint; what it does is "take the token, verify the signature, and if verified, return the username to the frontend":

    {
        url: '/api/repo',
        method: 'GET',
        timeout: 2000,
        response: ({headers })=>  {
            const token = headers['authorization'].split(' ')[1];
            console.log(token);
            try{
                const decoded = jwt.verify(token,secret);
                console.log(decoded);
                return {
                    code:0, // No error occurred
                    data:decoded.user
                }
            }catch(err){
                return {
                    code:401, // Error occurred
                    msg:'Token verification failed, invalid token'
                }
            }
            return {
                code:0, // No error occurred
                token
            }
        }
    }
]

This line is worth pausing to look at:

const token = headers['authorization'].split(' ')[1];

Why .split(' ')[1]? Because the Authorization header sent by the frontend looks like this:

Authorization: Bearer xxxx.yyyy.zzzz

The Bearer in front is a fixed prefix, telling the server "the following string is a Bearer Token." So the server must first split by space and take the second segment to get the real token.

Bearer Token: A token type defined by RFC 6750. Bearer literally means "the ticket holder" — whoever holds this ticket is the legitimate identity. It does not bind to a specific client device; any client that gets this ticket can use it, so the security of the token itself is critical; losing it is equivalent to losing your identity.

3. Zustand: Making login state readable across the entire site

3.1 Why not use Context

In React, the old way to manage global state is createContext + useContext. This old way has two levels:

But for a state like login, our requirement is "global sharing, cross-route sharing" — the homepage needs to read it, the payment page needs to read it, the navigation bar needs to read it, and it must not be lost when routes switch. In this scenario, the Context approach becomes verbose, so Zustand is introduced.

Zustand: A lightweight state management library in the React ecosystem. "Zustand" means "state" in German. Its core is just one create function, which returns a custom hook. Components subscribe to state through this hook, and when the state changes, the component re-renders automatically. No Provider wrapping, no boilerplate code.

With Zustand, React development gains an elegant formula:

React App = UI Component + Store

3.2 Store file design

State management generates a store folder, called the "state warehouse." This project has two stores:

use.js is the focus of this lesson:

// Globally responsible for providing user identity state, storing user identity state
// Create store
import { create } from 'zustand';

// Adopt hooks programming, custom hooks
export const useAuthStore = create((set) => ({
  // Responsible for defining state (properties), initial values
  // set is the method to modify state
  token: localStorage.getItem('token') || '',
  user: JSON.parse(localStorage.getItem('user')) || null,

  // action behavior methods, responsible for defining methods to modify state, ensuring external code can only modify state using the set method, not arbitrarily directly modifying state

  // Use localStorage to store token and user information
  setUser: (token, user) => {
    localStorage.setItem('token', token);
    localStorage.setItem('user', JSON.stringify(user));
    set({ token, user });
  },
  // Logout
  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    // hook reactive state update, notifies components to re-render
    set({ token: '', user: null });
  },
}));

Reading this store, I learned several things:

1. Initial values are read from localStorage: token: localStorage.getItem('token') || ''. This line answers the question I asked in the first section — HTTP is stateless, so when I refresh the page, is "who I am" lost? Answer: as long as the token is written into localStorage, when the store re-initializes after a refresh, it will read the token back. localStorage is browser local storage; it is not lost when the page is closed or refreshed, only when manually cleared or expired. So the login state is "preserved" this way — it's not that HTTP remembers me, but that the browser remembers for me.

2. set is the only entry point to modify state: The comment "ensuring external code can only modify state using the set method, not arbitrarily directly modifying state" is Zustand's discipline. Components cannot directly do store.token = 'xxx'; they must modify it through actions like setUser for the state to trigger a reactive update.

3. setUser synchronously writes to localStorage and calls set: This step is a dual write — both persistence (localStorage) and notification to components (set). Missing either one won't work: only writing to localStorage means components won't re-render; only calling set means it's lost on refresh.

4. logout does two things: Clears localStorage, then sets to an empty state. This is exactly what logout should look like — not only clearing the state in memory, but also tearing up the ID card stored locally in the browser.

3.3 todos.js: When to actually use Zustand

There is a very honest comment in the notes:

// Sub-store for todos state, only large projects need to use zustand for state management
// Small to medium projects should still use traditional createContext and useContext for state management, don't introduce the zustand package and add complexity
import { create } from 'zustand';

// create is a higher-order function, accepts a function as an argument, returns a function
// This function is the store instance, which can be used to get state and modify state

export const useTodosStore = create((set) => ({
  todos: [],
  // action
  setTodos: (todos) => set({ todos }),
}));

Higher-order function: A function that receives a function as an argument or returns a function. Zustand's create receives a function (this function receives set and returns a state object) and returns a new function (which is the useAuthStore hook we use). So create is a higher-order function.

The teacher's view is very pragmatic: Don't jump to Zustand for small to medium projects; Context is sufficient. Zustand is a tool for when large projects have many cross-route, cross-component states and the Context boilerplate code becomes annoying. This kind of judgment — "what tool for what scenario" — is more important than the tool itself.

4. Axios interceptors: Automatically stuffing the token into every request

4.1 What is an interceptor

Axios "quietly does a lot." Interceptors are the epitome of this "quietness" — they automatically step in before every request is sent and after every response comes back.

The two actions given in the notes are:

4.2 config.js full text

The code is in api/config.js:

import axios from 'axios';

// Singleton pattern
const instance = axios.create({
  baseURL: '/api',
  timeout: 5000,
});

// Persistent storage of token, because every request needs to carry it

// Intercept every request, use a configuration function to add the token to each request
instance.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if(token){
      config.headers['authorization'] = `Bearer ${token}`;
    }
    // Request configuration object
    return config;
  }
);

// Intercept every response, use a configuration function
instance.interceptors.response.use(
  response => {
    return response.data;
  }
);

export default instance; 

A few points I want to clarify when I review this myself:

1. Singleton pattern: The comment "Singleton pattern" means axios.create creates only one instance, and the whole project uses this one. The baseURL /api is set once, and all request paths automatically prepend /api.

Singleton pattern: A class has only one instance and provides a global access point. Here, instance is this globally unique axios instance; all API files import it to send requests. This way, configurations like baseURL, interceptors, and timeout are written only once and take effect across the entire project.

2. Token is read from localStorage, not from the store: The interceptor uses localStorage.getItem('token') instead of useAuthStore.getState().token. This is a detail — the interceptor is outside of axios, not part of the React component tree, so reading directly from localStorage is actually simpler. The token in the store and localStorage are synchronously dual-written, so reading from either is correct.

3. Bearer ${token} template literal: What is concatenated is exactly Bearer xxxx.yyyy.zzzz, corresponding precisely to the segment the server takes with .split(' ')[1] in section two.

4. The response interceptor only returns data: In business code, await instance.post('/login', data) directly gets { code, user, token }, no need for res.data.token, just res.token.

4.3 API files: The business layer only writes "which endpoint to call"

With config.js as the foundation, the API files in the business layer become very thin. api/user.js is just a login endpoint:

import instance from './config';

export const login = async (data) => {
    const res = await instance.post('/login', data);
    return res;
};

api/repo.js is just a protected endpoint:

import instance from './config';

export const getRepo = async () => {
    const res = await instance.get('/repo');
    console.log(res);
    return res.data;
}

Note a detail here that I also got stuck on: getRepo does return res.data, but config.js's response interceptor already returned response.data, so the res.data here is actually taking the .data field from the object { code:0, data:'admin' } returned by the backend — which is the actual username string 'admin'. The two layers of .data are not the same .data; the first layer is axios's, the second is the backend's business convention. I mixed them up at first, noting it down.

5. Route guard RequireAuth: No token, no entry to /pay

Authentication is not just "carrying a token in requests," but also "page-level denial of entry." This relies on route guards.

components/RequireAuth.jsx:

import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/use';

function RequireAuth({ children }) {
    const token = useAuthStore((state) => state.token);
    if (!token) {
        return <Navigate to="/login" replace />;
    }
  return (
    <div>
      {children}
    </div>
  );
}
export default RequireAuth;

Route guard: A component that checks permissions before a user enters a certain route. The logic here is simple — get the token from the store; if there's no token, <Navigate to="/login" replace /> redirects to the login page. replace means replacing the history entry, so the user won't return to this intercepted page when clicking back.

children is React's children prop — the child components wrapped by RequireAuth. It's clear when seen in App.jsx:

import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
// Route guard component
import RequireAuth from './components/RequireAuth';

import Nav from './components/Nav';

const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));

function App() {
  return (
    <Router>
      <Nav />
      <Suspense fallback={<div className="page">Loading…</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />
          <Route
            path="/pay"
            element={
              <RequireAuth>
                <Pay />
              </RequireAuth>
            }
          />
        </Routes>
      </Suspense>
    </Router>
  );
}
export default App;

The element for the /pay route is not directly <Pay />, but <RequireAuth><Pay /></RequireAuth>. So when accessing /pay, RequireAuth first checks the token — if not logged in, it kicks you to the login page; if logged in, it renders Pay.

lazy + Suspense (lazy loading): const Home = lazy(() => import('./pages/Home')) is React's route-level code splitting. Each page is packaged into a separate chunk, loaded only when the user accesses that route. Suspense's fallback is the placeholder UI shown during loading — here, just a "Loading…" message. The real page is displayed only after the first load completes, resulting in a smaller initial bundle and shorter white screen.

6. Login page: Form validation + the entry point for issuing tokens

The login page is the starting point of the entire authentication flow. The code is in pages/Login.jsx:

import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { login } from '../api/user';
import { useAuthStore } from '../store/use';
import styles from './Login.module.css';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from || '/';

  const setUser = useAuthStore(state => state.setUser);

  const [formData, setFormData] = useState({ username: '', password: '' });
  const [errors, setErrors] = useState({ username: '', password: '' });
  const [isValid, setIsValid] = useState(false);
  const [submitting, setSubmitting] = useState(false);

  // Form validation logic
  useEffect(() => {
    const newErrors = { username: '', password: '' };

    if (!formData.username.trim()) {
      newErrors.username = 'Username cannot be empty';
    } else if (formData.username.length < 3) {
      newErrors.username = 'Username must be at least 3 characters';
    }

    if (!formData.password.trim()) {
      newErrors.password = 'Password cannot be empty';
    } else if (formData.password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }

    setErrors(newErrors);
    setIsValid(!newErrors.username && !newErrors.password);
  }, [formData]);

  const handleChange = e => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleLogin = async e => {
    e.preventDefault();
    if (submitting) return;
    setSubmitting(true);
    try {
      const res = await login(formData); // Initiate login request
      if (res.code === 0) {
        setUser(res.token, res.user);
        navigate(from, { replace: true }); // Redirect after successful login
      } else {
        alert(res.message || 'Login failed');
      }
    } catch (err) {
      console.error(err);
      alert('Login failed');
    } finally {
      setSubmitting(false);
    }
  };

Here are the learning points I organized for myself:

1. from = location.state?.from || '/': This works together with RequireAuth. When a user originally wanted to enter /pay but was intercepted by the guard and kicked to the login page, the login page can read "where the user originally wanted to go" from location.state.from. After a successful login, navigate(from, { replace: true }) sends the user back to the page they originally wanted, rather than rudely sending everyone to the homepage. This "return to the original page after login" experience relies entirely on the location.state context passing.

2. Form validation uses useEffect to watch formData: Every time the input changes, useEffect recalculates errors, and isValid updates in real-time. The submit button has disabled={!isValid || submitting}, so it can't be clicked if the form is not filled correctly.

3. submitting prevents duplicate submissions: The line if (submitting) return; prevents the user from frantically clicking the login button. After clicking, setSubmitting(true) is immediately called, the button text changes to "Logging in…", and in the finally block, setSubmitting(false) restores it.

4. After successful login, call setUser: The line setUser(res.token, res.user) is the interface between the login page and the store. setUser internally dual-writes to localStorage and the store, and the next second, the entire site "knows" I am logged in.

5. res.code === 0: The business-convention success code. code: -1 means incorrect username or password, code: 401 means token verification failed, and code: 0 means success.

The JSX part of the login page also shows a detail — hint text:

<p className={styles.hint}>
  Demo account: <code>admin</code> · Password <code>123456</code>
</p>

7. Homepage: Calling /api/repo to verify the token

What the homepage does is string the entire authentication chain together and show it to the user. The code is in pages/Home.jsx:

import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getRepo } from '../api/repo';
import { useAuthStore } from '../store/use';

function Home() {
  const token = useAuthStore((s) => s.token);
  const user = useAuthStore((s) => s.user);

  const [repoUser, setRepoUser] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  // When logged in, carry the token to request the protected endpoint /api/repo, verifying the token
  useEffect(() => {
    if (!token) return;
    let cancelled = false;
    (async () => {
      setLoading(true);
      setError('');
      try {
        const data = await getRepo(); // Returns decoded.user (username)
        if (!cancelled) setRepoUser(data);
      } catch (e) {
        if (!cancelled) setError('Failed to get authentication info, token may have expired');
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [token]);

This useEffect is a classic pattern for asynchronous data fetching in React; I hadn't seen this cancelled flag pattern in other lessons before, so I'm noting it down:

1. if (!token) return;: Don't send the request if not logged in, avoiding an immediate 401 error upon entering the homepage.

2. let cancelled = false;: When the component unmounts, the cleanup function sets cancelled to true. If the request comes back after the component has unmounted (e.g., the user navigated away), it no longer calls setState, avoiding React's "can't perform a React state update on an unmounted component" warning.

3. data = await getRepo(): getRepo has already stripped one layer of .data internally; here, data is the data field from the backend's { code:0, data:'admin' }, which is the string 'admin'. It is directly set to repoUser for display.

The entire chain is visualized in the homepage UI:

The homepage also draws the entire authentication flow as three steps:

  1. Login to get Token: Submit username and password; the server verifies and issues a JWT.
  2. Carry Token in requests: The axios interceptor automatically attaches the Authorization request header.
  3. Server verifies signature and grants access: Decrypts the token, verifies identity, and returns protected data.

These three steps are the entire main thread of this lesson.

8. Nav bar: Display login/logout based on token

components/Nav.jsx demonstrates how "global state drives UI":

function Nav() {
  const token = useAuthStore((state) => state.token);
  const user = useAuthStore((state) => state.user);
  const logout = useAuthStore((state) => state.logout);

  const handleLogout = () => {
    logout();
  };

Note that Nav has no props; it has no data passing with its parent component App, directly getting token, user, and logout from the store. This is the benefit of Zustand — any component, as long as it imports this hook, can subscribe to the same global state. Unlike Context, which requires wrapping a Provider in a parent component.

The UI switching logic is also very direct:

{!token && (
  <NavLink to="/login" ...>Login</NavLink>
)}
// ...
{token ? (
  <>
    <span className="user-chip">
      <span className="user-dot" />
      {user?.username}
    </span>
    <button type="button" className="btn btn-ghost nav-logout" onClick={handleLogout}>
      Logout
    </button>
  </>
) : (
  <Link to="/login" className="btn btn-primary">
    Go to Login
  </Link>
)}

When not logged in, it shows a "Go to Login" button; when logged in, it shows the username + logout button. When the state changes, the UI automatically switches — this is the actual effect of Zustand's reactive updates.

9. Pay page: What a protected page looks like

pages/Pay.jsx itself has very light logic, just displaying a truncated segment of the current user's token to prove "you are logged in":

<code className="pay-value pay-token">
  {token ? `${token.slice(0, 44)}…` : '-'}
</code>

token.slice(0, 44) takes the first 44 characters and adds an ellipsis, because the full token is too long and looks bad. The real gatekeeper of this page is RequireAuth — accessing /pay without logging in will directly kick you to /login, and you won't even reach here. The fact that the Pay page can be displayed is itself proof that authentication is in effect.

10. vite-plugin-mock: Letting the frontend run the entire chain on its own

The last piece of the puzzle is the vite configuration. The code is in vite.config.js:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'

// https://vite.dev/config/
viteMockServe({
  mockPath: 'mock',
  localEnabled: true,
}),

vite-plugin-mock: Vite's mock plugin. After configuring mockPath: 'mock', Vite will automatically load all mock files in the mock/ folder under the project root and register them as corresponding HTTP endpoints. localEnabled: true means it is enabled in the local development environment. This way, when the frontend writes an axios request to /api/login, it doesn't need a real backend at all; the mock file handles it.

The teacher calls this approach "Big Frontend implementing authentication verification" — meaning the entire authentication process doesn't need to wait for the backend; the frontend uses mocks to run the whole chain itself. Signing, verification, interceptors, route guards, state management — every link is real code, only the "backend" link is substituted with mocks. When the real backend is ready, just turn off the mock configuration and point baseURL to the real backend; the frontend code doesn't need a single line changed.

The notes write about the combination of axios baseURL + vite mockjs plugin + /api/ path requests; these three things together form this Big Frontend authentication solution.

My current understanding

The takeaway from this lesson is not how to use a specific API, but breaking down "authentication" into a mechanism with 5 gears:

  1. JWT: The ID card itself, issued and verified by the server.
  2. localStorage: The ID card's home on the client side, not lost on refresh.
  3. Zustand store: Reads the ID card from localStorage into React's memory, subscribable across the entire site.
  4. Axios interceptor: Automatically stuffs the ID card into the Authorization header for every request.
  5. Route guard: Page-level ID card check; no card, no entry.

HTTP itself is indeed stateless. But "preserving the login state" never relied on HTTP to remember me — it's the browser (localStorage) + frontend code (store + interceptor + guard) that continuously, automatically, and repeatedly declare "who I am" with every request. The server just needs to verify the signature each time; it doesn't need to remember me.

This line of thinking is especially worth clarifying in the era of AI programming: in the future, when using AI to write authentication code, the AI will likely generate this entire set in one go. But if I don't know what each of these 5 gears does and why this division of labor exists, I won't even notice if the AI changes one place incorrectly. Tools will write code for me, but they can't replace me knowing "why this system is the way it is." This is the true value of this lesson.

Terminology Quick Reference