write-react-vite-code
Load this skill when writing React code in a Vite project: components, hooks, React Router, state, data fetching, Vite env vars. Auto-load when a .tsx file or vite.config.ts is created or modified.
SKILL.mdplugins/standalone/ceh-web-frontend/skills/write-react-vite-code/SKILL.md
GitHubWrite React + Vite Code
Write React components that render props, with effects and data fetching in hooks and every network call going through one API client. Done when no component fetches directly and no secret reaches the bundle.
Procedure
- Write the component presentational: props in, callbacks out, one component per file.
- Put effects and data fetching in a
use*hook that owns one concern. - Keep state local until it is genuinely shared across distant components.
- Send every request through
apiClient, and read config fromVITE_-prefixed vars only. - Show errors through the central message map with the
correlation_id, under an error boundary.
Rules
Components: presentational by default
Components render props and call callbacks. Side effects and data fetching live in hooks, not in render.
type Props = {
items: Item[];
onItemClick: (id: string) => void; // callback, not a direct store write
};
export function ItemPanel({ items, onItemClick }: Props) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<button onClick={() => onItemClick(item.id)}>{item.label}</button>
</li>
))}
</ul>
);
}
- One component per file; filename
PascalCase.tsxmatching the component name. - No business logic in components — extract it into hooks or
src/libmodules. - Always type props explicitly; never use
any(seeceh-web-frontend:configure-bun-vite-env).
Hooks
- Follow the Rules of Hooks: call hooks at the top level only, never conditionally.
- Custom hooks are named
use*and own one concern (data fetching, subscription, derived state). - Specify exhaustive
useEffectdependency arrays — do not silence the lint rule. - Prefer derived values computed during render over
useState+useEffectmirrors. - StrictMode mounts every component twice in development, so an effect that performs a one-time
action (send a message, create a session, log an analytics event) fires twice. Make the action
idempotent, or guard it with a
useRef(false)flag set on the first run.
export function useSession(sessionId: string) {
const [state, setState] = useState<SessionState | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
apiClient
.getSession(sessionId)
.then((s) => active && setState(s))
.catch((e) => active && setError(toMessage(e)));
return () => {
active = false;
};
}, [sessionId]);
return { state, error };
}
State management
- Local UI state:
useState/useReducer. - Shared server state: a data-fetching library (TanStack Query) or a small store — not prop drilling through many layers.
- Do not reach for a global store until state is genuinely shared across distant components.
Render cost
A state change re-renders the owning component and its whole subtree, so where state lives decides what re-renders.
- Keep state in the lowest component that needs it. State lifted to a page re-renders the page on every keystroke.
- Key list items by a stable id, never the array index, when the list can reorder, insert, or delete. An index key re-mounts rows and mixes up their local state.
- Every consumer of a context re-renders when its value changes. Pass a
useMemo'd value, never an inline object, and split a context whose parts change at different rates. - Define the config objects a library compares by reference (graph styles, grid column definitions, editor extensions) at module level. Created inside a component, a new object on every render makes the library tear down and remount its children.
- Reach for
memo,useMemo, anduseCallbackonly after the React DevTools Profiler shows a slow render. Unmeasured memoization adds code and dependency-array bugs for no gain. - Render long lists (hundreds of rows) through a virtualizer, and load route components with
React.lazyso a page's code ships when it is visited.
Routing
Use React Router. Define routes in one place; keep route components thin (they compose hooks + presentational components).
const router = createBrowserRouter([
{ path: "/", element: <Home /> },
{ path: "/sessions/:sessionId", element: <SessionPage /> },
]);
- Read route params with
useParams, navigate withuseNavigate— never mutatewindow.location.
Centralized API client
All fetch calls go through src/lib/api/client.ts. Components and hooks never call fetch directly, so the base URL, headers, and error parsing live in one place and tests mock one boundary.
export const apiClient = {
async sendMessage(
sessionId: string,
content: string,
): Promise<MessageResponse> {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/sessions/${sessionId}/message`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!response.ok) {
const err = await response.json();
throw new ApiRequestError(response.status, err.error);
}
return response.json();
},
};
- Cookie auth against an API on another origin needs
credentials: "include"on every request in the client. Without it the browser drops the cookie and every call fails auth. - When a 401 triggers a token refresh, concurrent 401s share one in-flight refresh promise. Each refreshing on its own makes the second refresh fail on the already rotated token, which logs the user out.
- A streaming endpoint that needs POST or an auth header is read with
fetchand aReadableStreamreader, becauseEventSourcesends only GET and no custom headers.
Environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL; // exposed to the browser — VITE_ prefix required
- Only
VITE_-prefixed vars are exposed to client code. Never put secrets in them. - Server-only secrets belong in a backend, never in a Vite frontend bundle.
Error handling
class ApiRequestError extends Error {
constructor(
public readonly status: number,
public readonly error: ApiError,
) {
super(error.message);
}
}
- Never expose internal error codes or stack traces to users.
- Map
error.codevalues to user-friendly messages in a centralized map. - Always surface the
correlation_idso users can report it. - Wrap route subtrees in an error boundary so one failure does not blank the app.