Client Layer
How the client side is structured. Components, SWR hooks, and Zustand
The client layer is split across three folders:
| Folder | Job |
|---|---|
components/ | Render UI |
hooks/ | Fetch data via SWR |
store/ | Manage client-side state via Zustand |
Components
Components are purely presentational. They don't fetch, they don't mutate, and they don't own business logic:
// ✅ UI logic - deciding what to render based on state. Fine.
function InvoiceRow({ invoice }: InvoiceRowProps) {
const isOverdue = invoice.dueDate < new Date();
return (
<tr className={isOverdue ? 'text-red-600' : ''}>
<td>{invoice.reference}</td>
<td>{formatCurrency(invoice.amount)}</td>
</tr>
);
}/**
* ❌ Business logic - calculating whether an invoice is overdue belongs in the service layer
* not the component. The component shouldn't know the business rule,
* only how to display the result of it.
*/
function InvoiceRow({ invoice }: InvoiceRowProps) {
const isOverdue =
invoice.status !== 'paid' &&
invoice.dueDate < new Date() &&
!invoice.gracePeriodActive;
// ...
}The test for whether logic belongs in a component: Could this rule change based on business decisions alone (not UI decisions)? If yes, it's business logic and belongs in the server layer or a hook.
Structure rules:
-
Single-file components are flat
kebab-name.tsx. Promote to a folder only when the component grows real parts: sub-components, split types, helpers.theme-toggle.tsx// flat - single file, no sub-partsindex.tsx// entry point - composes all sub-componentsnav-main.tsx// sub-component - main nav linksnav-user.tsx// sub-component - user avatar + menusettings-items.tsx// sub-component - settings links -
components/ui/- shadcn primitives -
components/common/- shared across features (layout, error states, auth):theme-toggle.tsxauth.tsxerror-display.tsx -
components/features/- domain-coupled components that only make sense within a specific feature:counter.tsx// flat - small, no sub-partsposts.tsx// flat - small, no sub-partsindex.tsx// public entry point - ExampleItemsform.tsx// ItemFormValues, ItemFormFields, CreateFormitem-row.tsx// ItemList, ViewRow, EditRow
example-items earned a folder here because it grew three distinct concerns:
shared form fields and types, the create form mutation, and the list/row
display with inline edit mode.
Data fetching with SWR
SWR handles loading state, error state, caching, deduplication, and revalidation
out of the box. All client-side fetching goes through
SWR hooks in hooks/, one hook per data domain.
SWR types error as any - always narrow it to Error before returning.
// hooks/use-example-items.ts
export function useExampleItems(): UseExampleItemsReturn {
const { data, isLoading, error, mutate } =
useSWR<ExampleItemsResponse>('/api/example-items');
return {
items: data?.exampleItems,
isLoading,
// SWR types error as `any` - narrow to Error so callers get a stable shape.
error: error instanceof Error ? error : undefined,
mutate,
};
}The mutate function is exposed so components can apply optimistic updates within the UI,
pass { optimisticData, rollbackOnError: true }and SWR reverts automatically on failure.
The alternative to using SWR (useEffect + useState) means
writing a dedicated version of it yourself, which is much more convoluted:
// Without SWR - ~50 lines to fetch one resource
export function useItem(id: string) {
const [isLoading, setIsLoading] = useState(true);
const [item, setItem] = useState<Item | null>(null);
const [error, setError] = useState<Error | null>(null);
const fetchItem = useCallback(async () => {
if (!id) {
setError(new Error('ID is required'));
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
const response = await fetch(`/api/items/${encodeURIComponent(id)}`);
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
setItem(await response.json());
} catch (err) {
setError(err as Error);
setItem(null);
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
fetchItem();
}, [fetchItem]);
return { isLoading, item, error, refetch: fetchItem };
}Every hook then repeats this boilerplate. With SWR the same hook is a handful of lines.
TanStack Query is the other alternative option and is worth looking at if you need more granular cache control.
An SWR hook should in most cases call an internal /api/* route:
useExampleItems→GET /api/example-items→ the DB service.usePosts→GET /api/posts→ thepostsintegration service, which fetches JSONPlaceholder server-side.
usePosts going through a route + service is illustrative.
JSONPlaceholder is a public no-auth GET, so realistically the hook could hit
it directly and skip the server layer entirely. It's wired the long way purely
to demonstrate the server-integration pattern end to end. The rule for real
code: reach for a classes/services/ class only when there's auth, a secret,
or response shaping to own - otherwise the client → SWR → fetcher path is
enough. See Server Layer → When do you need a service
class?
State management with Zustand
Reach for a store when the same piece of client state has to be read or updated
by components that don't share a direct parent-child line. If the state stays inside one
component or flows one level down, plain useState is enough, don't reach for a
store.
The alternative to a store is prop drilling threading the value and its setter down through every intermediate component that doesn't itself care about it just to reach the one that does. That couples unrelated components to the shape of the state and turns a small change into an edit across the whole chain. A store lets any component subscribe directly to the slice it needs, no intermediaries.
Zustand stores live in store/, one file per domain. The state shape and
actions are always defined as separate types and intersected.
The counter below is a reference example, not a real need. Its state lives in
one component, so by the rule above it should just be useState. It's a store
purely to demonstrate the pattern in a copy-pasteable shape, don't take it as
a cue to reach for Zustand whenever you have a counter.
// store/counter-store.ts
type CounterState = {
count: number;
};
type CounterActions = {
increment: () => void;
decrement: () => void;
reset: () => void;
};
export type CounterStore = CounterState & CounterActions;
// TypeScript enforces that every state field has a default here.
// Add a field to CounterState and the compiler errors until you add it here too.
export const defaultCounterState: CounterState = {
count: 0,
};
export const useCounterStore = create<CounterStore>()((set) => ({
...defaultCounterState,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
// Safe reset - only resets state fields, never touches actions.
reset: () => set(defaultCounterState),
}));Components subscribe to only the slices they need, this prevents unnecessary re-renders when unrelated state changes:
// Correct - subscribe to individual slices
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
// Wrong - subscribes to the whole store, re-renders on any change
const store = useCounterStore();Let's also just briefly touch on the more simplistic type layout alternative and why this can be sub-optimal:
// Simpler, but loses two guarantees
export type CounterStore = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};The problem here is you can no longer derive a correctly-typed default state. With a
merged type, defaultCounterState has to be typed as Partial<CounterStore>
(actions aren't state) or cast with as (slightly unsafe). The reset implementation is the other concrete win. With the merged type
you'd have to manually list the fields to reset:
// Brittle - easy to forget a field when state grows
reset: () => set({ count: 0, someNewField: '' }),With the split, reset is always correct by construction, add a field to
CounterState and its default is automatically included:
// Always resets exactly the right fields, nothing more
reset: () => set(defaultCounterState),