DEV Community

Kamran Khan
Kamran Khan

Posted on

The spinner that kills the auth flash: a loading gate before every protected route

Refresh any half-built React + Laravel app while logged in and you'll see it: the dashboard renders for a split second — no user name, a broken avatar, maybe a flash of the login page first. Then auth "catches up" and everything snaps into place. That flicker is the auth flash, and the fix is embarrassingly small: don't render anything until you know who the visitor is.

Where the flash comes from

My auth state lives in an AuthContext. On mount, if there's a token in localStorage, it calls GET /auth/me to turn that stored token from a claim into a fact. That call is async. Meanwhile React has already rendered. So there's a window — usually a few hundred milliseconds — where user is null but the visitor is actually authenticated. Any route guard that checks user synchronously will make the wrong call in that window: either bouncing a real user to /login, or rendering the dashboard with null where the user should be.

The gate: a loading flag, checked first

The context exposes a loading flag that starts true and only flips to false after the /me call settles — success or failure:

const [loading, setLoading] = useState(true);

useEffect(() => {
  const stored = localStorage.getItem('saas_token');
  if (!stored) {
    setLoading(false);
    return;
  }
  client.get('/auth/me')
    .then((res) => { setUser(res.data.user); setToken(stored); })
    .catch(() => {
      localStorage.removeItem('saas_token');
      setUser(null);
      setToken(null);
    })
    .finally(() => setLoading(false));
}, []);
Enter fullscreen mode Exit fullscreen mode

Note the catch: a dead token doesn't just fail — it gets removed, so the next page load skips the /me call entirely and goes straight to logged-out. And every protected route checks loading before it checks anything else:

export default function ProtectedRoute({ children }) {
  const { user, token, loading } = useAuth();

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-100">
        <div className="h-10 w-10 animate-spin rounded-full border-4 border-indigo-200 border-t-indigo-600" />
      </div>
    );
  }

  if (!token || !user) {
    return <Navigate to="/login" replace />;
  }

  return children;
}
Enter fullscreen mode Exit fullscreen mode

The spinner isn't decoration — it's the third auth state. Logged in, logged out, and don't know yet. Most auth bugs I've seen come from systems that only model the first two.

The admin variant

The same file holds AdminRoute, which adds one line of real logic:

if (user.role !== 'admin') {
  return <Navigate to="/" replace />;
}
Enter fullscreen mode Exit fullscreen mode

A non-admin hitting /users lands on the dashboard, not on a 403 page — deliberate UX: the route simply doesn't exist for them. (The API still enforces role:admin server-side through the middleware; the frontend redirect is courtesy, not security. Never confuse the two.)

The route tree composes it plainly:

<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
  <Route index element={<Dashboard />} />
  <Route path="/users" element={<AdminRoute><Users /></AdminRoute>} />
</Route>
Enter fullscreen mode Exit fullscreen mode

The whole Layout — sidebar, header, all of it — renders only inside ProtectedRoute, so an unauthenticated visitor never even sees the app shell. /login and /register sit outside the guard, public as they should be.

The rule

Never render an auth-dependent UI on user == null meaning "logged out". null means "haven't asked the server yet" until the loading flag says otherwise. One boolean, checked first in every guard, and the flash is gone — on refresh, on deep links, on the back button. It's the cheapest reliability win in the whole frontend.


The auth flow above — Sanctum tokens, the /me re-verification, the route guards — ships in my Laravel + React SaaS Starter Kit ($19 one-time).

Top comments (0)