React Integration
This guide shows how to integrate Ogiri token authentication into a React app.
No npm package required. Copy the two files from
sample/sample-react/src/lib/directly into your project. They have zero runtime dependencies.
1. Copy the auth primitives
Copy these two files into your project (e.g. src/lib/):
| File | Purpose |
|---|---|
auth.ts | Token types, storage, OgiriAuth state manager, pure injectAuth/extractTokens functions |
axios-ogiri.ts | Axios interceptors — only needed if you use axios |
auth.ts has no external dependencies. axios-ogiri.ts depends only on axios (which you already have).
2. Create the auth instance
// src/api/client.ts
import { OgiriAuth, LocalStorageTokenStorage } from "../lib/auth";
import { createAxiosInterceptors } from "../lib/axios-ogiri";
import axios from "axios";
export const auth = new OgiriAuth({
authMethod: "headers", // or "bearer" — match your server config
storage: new LocalStorageTokenStorage(), // persists across page reloads
});
export const api = axios.create({ baseURL: "https://api.example.com" });
const { request, response } = createAxiosInterceptors(auth);
api.interceptors.request.use(request);
api.interceptors.response.use(response.onFulfilled, response.onRejected);
The interceptors handle three things automatically:
- Request: injects
access-token,client,uid,expiry,token-typeheaders - Response: extracts rotated token headers and updates stored tokens
- 401: clears stored tokens and fires
onAuthErrorif configured
3. Expose auth state to React with useSyncExternalStore
OgiriAuth is a plain event emitter (subscribe/notify). Hook it into React's external store primitive so components re-render when tokens change:
// src/auth/AuthProvider.tsx
import {
createContext,
useCallback,
useMemo,
useSyncExternalStore,
type ReactNode,
} from "react";
import { auth } from "../api/client";
import type { OgiriTokens } from "../lib/auth";
interface AuthContextValue {
isAuthenticated: boolean;
tokens: OgiriTokens | null;
login: (tokens: OgiriTokens) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const tokens = useSyncExternalStore(
(cb) => auth.subscribe(cb), // subscribe
() => auth.getTokens() // getSnapshot
);
const login = useCallback((t: OgiriTokens) => auth.setTokens(t), []);
const logout = useCallback(() => auth.clearTokens(), []);
const value = useMemo(
() => ({ isAuthenticated: tokens !== null, tokens, login, logout }),
[tokens, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
useSyncExternalStore is the React 18+ standard for subscribing to non-React state. It correctly handles concurrent rendering without tearing.
4. Consume auth state in components
// src/auth/useAuth.ts
import { useContext } from "react";
import { AuthContext } from "./AuthProvider";
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
// Protected route
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Navigate to="/login" replace />;
return children;
}
5. Handle login
After a successful login POST, the server returns tokens in the response body and sets them in response headers (for rotation). Store whichever you receive:
// src/api/queries.ts
export function useLogin() {
return useMutation({
mutationFn: (creds: { username: string; password: string }) =>
api.post("/api/auth/login", creds),
onSuccess: ({ data }) => {
// Map server response fields to OgiriTokens
auth.setTokens({
accessToken: data["access-token"],
client: data.client,
uid: data.uid,
expiry: data.expiry,
tokenType: data["token-type"],
});
},
});
}
After login, every subsequent request automatically carries the current tokens. When the server rotates tokens (sends new headers), the response interceptor updates storage and notifies AuthProvider — components re-render with the new tokens without any extra code.
6. Token rotation display
The sample app demonstrates live token rotation using a prevToken ref:
// src/components/TokenDisplay.tsx — from sample-react
export function TokenDisplay({ tokens }: { tokens: OgiriTokens | null }) {
const prevRef = useRef<string | null>(null);
const [highlight, setHighlight] = useState(false);
useEffect(() => {
const current = tokens?.accessToken ?? null;
if (prevRef.current !== null && prevRef.current !== current) {
setHighlight(true);
const t = setTimeout(() => setHighlight(false), 1000);
return () => clearTimeout(t);
}
prevRef.current = current;
}, [tokens?.accessToken]);
if (!tokens) return <p>No tokens</p>;
return (
<div className={highlight ? "rotated" : ""}>
<code>{tokens.accessToken.substring(0, 8)}…</code>
</div>
);
}
7. HTTP clients
OgiriAuth exposes three primitives that map onto any HTTP client:
| Primitive | What it does |
|---|---|
auth.injectInto(config) | Returns a new RequestInit with auth headers merged in |
auth.extractFrom(response) | Reads rotation headers from a Response and updates stored tokens |
auth.headerInjector() | Returns (headers) => headers — merges auth into a plain object |
Full source: sample/sample-react/src/api/client.ts and src/lib/axios-ogiri.ts.
import { OgiriAuth, LocalStorageTokenStorage } from "./lib/auth";
import { createAxiosInterceptors } from "./lib/axios-ogiri";
import axios from "axios";
export const auth = new OgiriAuth({
authMethod: "headers",
storage: new LocalStorageTokenStorage(),
});
export const api = axios.create({ baseURL: "https://api.example.com" });
const { request, response } = createAxiosInterceptors(auth);
api.interceptors.request.use(request);
api.interceptors.response.use(response.onFulfilled, response.onRejected);
The createAxiosInterceptors function in axios-ogiri.ts bridges axios's non-standard InternalAxiosRequestConfig/AxiosResponse types to the RequestInit/Response primitives that auth.injectInto() and auth.extractFrom() expect.
No adapter file needed — auth.injectInto() and auth.extractFrom() accept the standard Fetch API types directly.
// src/api/client.ts
import { OgiriAuth, LocalStorageTokenStorage, OgiriAuthError } from "./lib/auth";
export const auth = new OgiriAuth({
authMethod: "headers",
storage: new LocalStorageTokenStorage(),
});
export async function ogiriFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(
`https://api.example.com${path}`,
auth.injectInto(options),
);
if (response.status === 401) {
const body = await response.json().catch(() => null);
throw auth.handleAuthError(body);
}
if (!response.ok) {
throw new Error(
`${options.method ?? "GET"} ${path} failed: ${response.status} ${response.statusText}`,
);
}
auth.extractFrom(response.clone()); // clone before consuming body
return response.json() as Promise<T>;
}
Login — tokens arrive in the response body on the first request, then rotate via headers:
const data = await ogiriFetch<LoginResponse>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
// ogiriFetch calls auth.extractFrom() — tokens stored automatically
ky uses standard Request/Response in its hook API, so no adapter file is needed. Use afterResponse (not afterResponseError — that hook does not exist in ky) to handle both rotation and 401s:
import ky from "ky";
import { OgiriAuth, LocalStorageTokenStorage } from "./lib/auth";
export const auth = new OgiriAuth({
authMethod: "headers",
storage: new LocalStorageTokenStorage(),
});
export const api = ky.create({
prefixUrl: "https://api.example.com",
hooks: {
beforeRequest: [
(request) => {
for (const [key, value] of Object.entries(auth.headerInjector()({}))) {
request.headers.set(key, value);
}
},
],
afterResponse: [
(_, __, response) => {
if (response.status === 401) {
auth.handleAuthError(null);
} else {
auth.extractFrom(response.clone());
}
return response;
},
],
},
});
ofetch's FetchResponse extends the standard Response, so auth.extractFrom() accepts it directly. onResponseError handles 401s separately from successful responses:
import { $fetch, type FetchOptions } from "ofetch";
import { OgiriAuth, LocalStorageTokenStorage } from "./lib/auth";
export const auth = new OgiriAuth({
authMethod: "headers",
storage: new LocalStorageTokenStorage(),
});
const baseOptions: FetchOptions = {
baseURL: "https://api.example.com",
onRequest: ({ options }) => {
options.headers = { ...(options.headers as object), ...auth.headerInjector()({}) };
},
onResponse: ({ response }) => {
auth.extractFrom(response);
},
onResponseError: ({ response }) => {
if (response.status === 401) auth.handleAuthError(null);
},
};
export const api = $fetch.create(baseOptions);
8. React state management
OgiriAuth is a plain observable — it has a subscribe(listener) method that fires whenever tokens change (login, logout, or rotation from an HTTP response). Any state manager can bridge it with a one-line subscription.
The built-in React 18 primitive. No extra dependencies. Full source: sample/sample-react/src/auth/AuthProvider.tsx
// src/auth/AuthProvider.tsx
import { createContext, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
import { auth } from "../api/client";
import type { OgiriTokens } from "../lib/auth";
interface AuthContextValue {
isAuthenticated: boolean;
tokens: OgiriTokens | null;
login: (tokens: OgiriTokens) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const tokens = useSyncExternalStore(
(cb) => auth.subscribe(cb), // subscribe — returns unsubscribe fn
() => auth.getTokens(), // getSnapshot
);
const login = useCallback((t: OgiriTokens) => auth.setTokens(t), []);
const logout = useCallback(() => auth.clearTokens(), []);
const value = useMemo(
() => ({ isAuthenticated: tokens !== null, tokens, login, logout }),
[tokens, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// src/auth/useAuth.ts — full source in sample-react
import { useContext } from "react";
import { AuthContext } from "./AuthProvider";
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
useSyncExternalStore guarantees tear-free reads under React 18 concurrent rendering. auth.subscribe returns an unsubscribe function, which is exactly what React expects.
Bridge OgiriAuth's subscribe into a Zustand store. Keep OgiriAuth as the source of truth — the store is a reactive read layer, not a second state owner.
// src/store/auth-store.ts
import { create } from "zustand";
import { auth } from "../api/client";
import type { OgiriTokens } from "../lib/auth";
interface AuthStore {
tokens: OgiriTokens | null;
isAuthenticated: boolean;
login: (tokens: OgiriTokens) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>(() => ({
tokens: auth.getTokens(),
isAuthenticated: auth.isAuthenticated(),
login: (tokens) => auth.setTokens(tokens),
logout: () => auth.clearTokens(),
}));
// Keep the store in sync with OgiriAuth (rotation from interceptors, external logouts, etc.)
auth.subscribe(() =>
useAuthStore.setState({
tokens: auth.getTokens(),
isAuthenticated: auth.isAuthenticated(),
}),
);
In components:
import { useAuthStore } from "../store/auth-store";
function Navbar() {
const { isAuthenticated, logout } = useAuthStore();
return isAuthenticated ? <button onClick={logout}>Logout</button> : null;
}
// Select only what you need to avoid unnecessary re-renders
function TokenBadge() {
const tokens = useAuthStore((s) => s.tokens);
return tokens ? <code>{tokens.accessToken.substring(0, 8)}…</code> : null;
}
Login — call auth.setTokens() (or the store's login action) after receiving tokens from the login endpoint. The subscribe callback updates the store automatically:
const { login } = useAuthStore.getState();
login({ accessToken: data["access-token"], client: data.client, ... });
// → auth.setTokens() fires → subscribe callback → store re-renders
@tanstack/store + @tanstack/react-store. Same pattern as Zustand: OgiriAuth owns the data, the Store is the reactive bridge.
// src/store/auth-store.ts
import { Store } from "@tanstack/store";
import { useStore } from "@tanstack/react-store";
import { auth } from "../api/client";
import type { OgiriTokens } from "../lib/auth";
interface AuthState {
tokens: OgiriTokens | null;
}
export const authStore = new Store<AuthState>({
tokens: auth.getTokens(),
});
// Keep the store in sync with OgiriAuth
auth.subscribe(() =>
authStore.setState(() => ({ tokens: auth.getTokens() })),
);
// Derived selectors
export const useTokens = () => useStore(authStore, (s) => s.tokens);
export const useIsAuthenticated = () => useStore(authStore, (s) => s.tokens !== null);
Actions stay on OgiriAuth directly — no need to duplicate them on the store:
// Login
auth.setTokens({ accessToken: data["access-token"], client: data.client, ... });
// Logout
auth.clearTokens();
In components:
import { useTokens, useIsAuthenticated } from "../store/auth-store";
function Navbar() {
const isAuthenticated = useIsAuthenticated();
return isAuthenticated ? <button onClick={() => auth.clearTokens()}>Logout</button> : null;
}
function TokenBadge() {
const tokens = useTokens();
return tokens ? <code>{tokens.accessToken.substring(0, 8)}…</code> : null;
}
useStore accepts a selector function, so components only re-render when the selected slice changes — equivalent to Zustand's selector pattern.
Full working example
See sample/sample-react/ for a complete app demonstrating:
- Login / logout flow with axios + React Query
- Protected routes with React Router
- Token rotation visualisation (
src/components/TokenDisplay.tsx) - MSW mock server replicating the full Ogiri auth protocol
useSyncExternalStorebridgingOgiriAuthto React context
Run it standalone (no Spring Boot required):
cd sample/sample-react
pnpm install
pnpm dev
# → http://localhost:5173
# Login: user1 / password