TypeScript Type System
Primitive & Basic Types
TypeScript's primitive types map directly to JavaScript's runtime values. Tuple types enforce both the length and the type at each position, making them useful for fixed-structure return values. Prefer unknown over any when a value's type is genuinely uncertain — it forces you to narrow before use, preventing silent runtime errors.
// Primitives
let name: string = "Hari";
let age: number = 30;
let active: boolean = true;
let nothing: null = null;
let undef: undefined = undefined;
// Arrays
let ids: number[] = [1, 2, 3];
let tags: Array<string> = ["api", "v2"];
// Tuples (fixed-length, typed positionally)
let pair: [string, number] = ["age", 30];
let rest: [string, ...number[]] = ["scores", 90, 85, 92];
// Object type (inline)
let user: { name: string; age: number; email?: string } = {
name: "Hari",
age: 30,
};
// Literal types
let direction: "up" | "down" | "left" | "right" = "up";
let httpCode: 200 | 404 | 500 = 200;
// any vs unknown vs never
let loose: any = "skip all checks"; // avoid this
let safe: unknown = fetchData(); // must narrow before use
function crash(): never { throw new Error(); } // never returnsTypeScript
Interfaces vs Types
Use interface for object shapes you may extend or that third-party code might augment via declaration merging. Use type for unions, intersections, and mapped types where an interface cannot be used. The ? modifier makes a property optional; readonly prevents reassignment after initialisation.
// Interface — extendable, mergeable (best for object shapes & APIs)
interface Device {
id: number;
serial: string;
name: string;
status: DeviceStatus;
restaurantId: string;
createdAt: Date;
}
// Extend
interface POSDevice extends Device {
terminalId: string;
lastTransaction?: Date;
}
// Type alias — for unions, intersections, primitives, mapped types
type DeviceStatus = "online" | "offline" | "maintenance";
type ID = string | number;
// Intersection (combine types)
type Timestamped = { createdAt: Date; updatedAt: Date };
type DeviceWithTimestamps = Device & Timestamped;
// readonly & optional
interface Config {
readonly apiUrl: string; // can't reassign
timeout?: number; // optional
headers: Record<string, string>; // { [key: string]: string }
}TypeScript
Enums & Const Objects
TypeScript enums compile to real JavaScript objects, which means they add runtime weight and can behave unexpectedly with tree-shaking. The preferred alternative is a const object paired with as const and a derived union type — this gives you the same autocomplete and type safety with zero runtime overhead.
// String enum
enum Status {
Online = "online",
Offline = "offline",
Maintenance = "maintenance",
}
// Prefer: const object + type (tree-shakeable, no runtime cost)
const STATUS = {
Online: "online",
Offline: "offline",
Maintenance: "maintenance",
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
// "online" | "offline" | "maintenance"TypeScript
Type Narrowing
Narrowing is how TypeScript refines a broad type to a more specific one inside a conditional branch. Discriminated unions — where every member shares a literal status or type field — are the cleanest pattern for complex state because the compiler can prove exhaustiveness. Custom type guards (obj is Device) let you push narrowing into reusable functions.
// typeof guard
function format(value: string | number): string {
if (typeof value === "string") return value.toUpperCase();
return value.toFixed(2);
}
// Discriminated union (the pattern for complex state)
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; error: string }
| { status: "loading" };
function handle<T>(result: Result<T>) {
switch (result.status) {
case "success":
return result.data; // TS knows data exists
case "error":
throw new Error(result.error);
case "loading":
return null;
}
}
// in operator
if ("terminalId" in device) { // now typed as POSDevice }
// Custom type guard
function isDevice(obj: unknown): obj is Device {
return typeof obj === "object" && obj !== null && "serial" in obj;
}TypeScript
Advanced TypeScript
Generics
Generics let you write functions and interfaces that work over a range of types while preserving type information. The extends keyof T constraint ensures the key argument is always a valid property name on the object, catching mistakes at compile time rather than runtime. Generic component props (like ListProps<T>) are the React-specific pattern for building reusable, type-safe list and table components.
// Generic function
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// Constrained generic
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Generic interface
interface ApiResponse<T> {
data: T;
meta: { page: number; total: number };
}
// Generic component props
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
}TypeScript
Utility Types
Utility types transform existing types rather than defining new ones from scratch, keeping your codebase DRY. Partial is essential for update/patch payloads; Omit and Pick are used constantly to derive API request types from full model types. Awaited and ReturnType are invaluable for typing things derived from async functions without duplicating the return signature.
// Partial — all props optional
type UpdateDevice = Partial<Device>;
// Required — all props required
type FullDevice = Required<Device>;
// Pick — select specific props
type DeviceSummary = Pick<Device, "id" | "name" | "status">;
// Omit — exclude props
type CreateDevice = Omit<Device, "id" | "createdAt">;
// Record — typed key-value map
type StatusCount = Record<DeviceStatus, number>;
// { online: number; offline: number; maintenance: number }
// Readonly — deeply immutable
type FrozenConfig = Readonly<Config>;
// Extract / Exclude — filter union members
type ActiveStatus = Extract<DeviceStatus, "online" | "maintenance">;
// "online" | "maintenance"
// ReturnType / Parameters
type FetchReturn = ReturnType<typeof fetchDevices>;
type FetchParams = Parameters<typeof fetchDevices>;
// Awaited — unwrap Promise
type DeviceData = Awaited<ReturnType<typeof fetchDevices>>;
// NonNullable — strip null | undefined
type DefiniteDevice = NonNullable<Device | null>;TypeScript
Mapped & Conditional Types
Mapped types iterate over the keys of an existing type and transform each property, enabling patterns like making every field nullable or optional in one line. Conditional types (T extends U ? X : Y) act like ternary expressions at the type level. The infer keyword lets you capture and name a sub-type found inside a complex type — the primary use case is extracting resolved values from Promise<T> or prop types from components.
// Mapped type — transform every property
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Conditional type
type IsString<T> = T extends string ? true : false;
// Template literal types
type EventName = `on${Capitalize<string>}`;
// matches "onClick", "onSubmit", etc.
// infer keyword
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
// Practical: extract props from a component
type PropsOf<C> = C extends React.ComponentType<infer P> ? P : never;TypeScript
Satisfies & Const Assertions
as const freezes a value to its narrowest literal type, preventing accidental widening to string or number. The satisfies operator (TypeScript 5) validates that a value conforms to a type without actually widening the inferred type — you get both the type-checking of an annotation and the precision of the inferred literal type.
// 'as const' — narrow to literal types
const routes = {
home: "/",
devices: "/devices",
settings: "/settings",
} as const;
// type: { readonly home: "/"; readonly devices: "/devices"; ... }
// 'satisfies' (TS 5) — validate type without widening
const palette = {
primary: [194, 104, 41],
secondary: "#1A8A7D",
} satisfies Record<string, string | number[]>;
// palette.primary is still number[] (not widened to string | number[])TypeScript
Functions & Modules
Function Signatures
TypeScript infers return types automatically, but explicitly annotating them acts as a correctness check — the compiler will catch any code path that returns the wrong shape. Function overloads let you express multiple call signatures for a single implementation, which is cleaner than a union return type for callers. Generic constraints (T extends object) narrow what type arguments are valid without sacrificing flexibility.
// Typed function
function greet(name: string, loud?: boolean): string {
const msg = `Hello, ${name}`;
return loud ? msg.toUpperCase() : msg;
}
// Arrow function with type
const double = (n: number): number => n * 2;
// Function type alias
type Formatter = (value: number) => string;
const currency: Formatter = (v) => `£${v.toFixed(2)}`;
// Overloads
function parse(input: string): Device;
function parse(input: string[]): Device[];
function parse(input: string | string[]): Device | Device[] {
if (Array.isArray(input)) return input.map(parseSingle);
return parseSingle(input);
}
// Generic function with constraint
function merge<T extends object>(a: T, b: Partial<T>): T {
return { ...a, ...b };
}TypeScript
Destructuring & Spread
TypeScript fully type-checks destructuring assignments — the type annotation goes on the whole pattern, not individual variables. Rest elements in destructuring (...rest) are typed as the remaining properties of the source object. When spreading into a new object, TypeScript narrows the type of overwritten properties only if you assert the value (as const).
// Object destructuring with types
const { name, status, ...rest }: Device = device;
// Function params destructuring
function createDevice({ serial, name }: Pick<Device, "serial" | "name">) { }
// Spread (arrays and objects)
const all = [...existing, newDevice];
const updated = { ...device, status: "online" as const };
// Rest params
function log(msg: string, ...tags: string[]) {
console.log(`[${tags.join(",")}]`, msg);
}TypeScript
Modules & Exports
TypeScript modules follow ES module syntax. Use import type for type-only imports — this guarantees the import is erased at compile time and never included in the runtime bundle. Barrel files (export * from) centralise public API surface but can hurt tree-shaking if bundler module analysis is weak, so use them deliberately.
// Named exports
export interface Device { /* ... */ }
export function createDevice(d: CreateDevice): Device { /* ... */ }
export const MAX_DEVICES = 100;
// Default export
export default function DeviceList() { /* ... */ }
// Imports
import DeviceList from "./DeviceList";
import { Device, createDevice } from "./devices";
import type { Device } from "./types"; // type-only (erased at compile)
import * as utils from "./utils";
// Re-export (barrel file)
export { Device, DeviceStatus } from "./device";
export * from "./restaurant";TypeScript
Async Patterns
TypeScript types async functions as returning Promise<T>, so the return annotation on the function body describes the resolved value. Promise.allSettled is safer than Promise.all when any individual request may fail — it always resolves, and you filter the fulfilled results using a typed predicate. Use AbortController to cancel in-flight requests, especially when a component unmounts before a fetch completes.
// Async / Await
async function fetchDevice(id: number): Promise<Device> {
const res = await fetch(`/api/devices/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Parallel execution
const [devices, restaurants] = await Promise.all([
fetchDevices(),
fetchRestaurants(),
]);
// Promise.allSettled — don't fail on one rejection
const results = await Promise.allSettled([
fetchDevice(1),
fetchDevice(2),
fetchDevice(999), // might fail
]);
const successful = results
.filter((r): r is PromiseFulfilledResult<Device> => r.status === "fulfilled")
.map(r => r.value);
// AbortController — cancellable fetch
const controller = new AbortController();
const res = await fetch(url, { signal: controller.signal });
controller.abort(); // cancel
// Error handling pattern
async function safeFetch<T>(url: string): Promise<Result<T>> {
try {
const res = await fetch(url);
if (!res.ok) return { status: "error", error: `HTTP ${res.status}` };
return { status: "success", data: await res.json() };
} catch (e) {
return { status: "error", error: (e as Error).message };
}
}TypeScript
React Core
Component Patterns
Typed functional components define a Props interface and destructure it directly in the parameter list — no need for React.FC, which adds an implicit children prop and is avoided in modern React. Use React.ReactNode for children (accepts JSX, strings, arrays, null) and generic components when the same component must handle multiple data shapes.
// Basic typed component
interface DeviceCardProps {
device: Device;
onSelect: (id: number) => void;
className?: string;
}
export function DeviceCard({ device, onSelect, className }: DeviceCardProps) {
return (
<div className={className} onClick={() => onSelect(device.id)}>
<h3>{device.name}</h3>
<span>{device.serial}</span>
<StatusBadge status={device.status} />
</div>
);
}
// With children
interface LayoutProps {
title: string;
children: React.ReactNode;
sidebar?: React.ReactNode;
}
export function Layout({ title, children, sidebar }: LayoutProps) {
return (
<div>
<header>{title}</header>
<main>{children}</main>
{sidebar && <aside>{sidebar}</aside>}
</div>
);
}
// Generic component
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
);
}TSX
Conditional & List Rendering
React renders false, null, and undefined as nothing, making short-circuit (&&) and ternary expressions the standard conditional rendering tools. When rendering from a discriminated union, a switch on the status field is exhaustive and gives TypeScript full knowledge of which properties exist in each branch. Always provide a stable, unique key on list items — using array index as a key causes bugs when items reorder.
// Conditional rendering patterns
{isLoading && <Spinner />}
{error ? <ErrorMsg error={error} /> : <DeviceList devices={data} />}
{device.status === "online" && <OnlineBadge />}
// Rendering a discriminated union
function StatusView({ result }: { result: Result<Device[]> }) {
switch (result.status) {
case "loading": return <Spinner />;
case "error": return <p>{result.error}</p>;
case "success": return <DeviceList devices={result.data} />;
}
}
// List with key
{devices.map(d => <DeviceCard key={d.id} device={d} onSelect={handleSelect} />)}TSX
Event Handling
React wraps native DOM events in a SyntheticEvent, and each event type has a corresponding TypeScript generic (e.g., React.ChangeEvent<HTMLInputElement>). Typing the event correctly gives you autocomplete on e.target properties. Always call e.preventDefault() in form submit handlers to stop the default browser navigation.
// Typed event handlers
function SearchBar() {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") submit();
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
</form>
);
}TSX
Hooks (Complete Guide)
useState
TypeScript infers the state type from the initial value, so useState(0) is automatically typed as number. When the initial value is null or an empty array, pass an explicit generic to avoid never[] or overly broad inferred types. Use the functional update form (prev => ...) whenever the new state depends on the old one — this avoids stale closure bugs in async contexts.
// Inferred type
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
// Explicit generic (when initial is null/undefined)
const [device, setDevice] = useState<Device | null>(null);
const [items, setItems] = useState<Device[]>([]);
// Functional update (when next state depends on previous)
setCount(prev => prev + 1);
setItems(prev => [...prev, newItem]);
setItems(prev => prev.filter(d => d.id !== idToRemove));TSX
useEffect
useEffect runs after every render by default; the dependency array controls when it re-runs. To safely run async code inside an effect, define an inner async function and call it — you cannot make the effect callback itself async. The cancellation flag pattern (let cancelled) prevents setting state on an unmounted component, which causes a React warning and potential memory leaks.
// Fetch on mount
useEffect(() => {
let cancelled = false;
async function load() {
const data = await fetchDevices();
if (!cancelled) setDevices(data);
}
load();
return () => { cancelled = true; }; // cleanup
}, []); // empty deps = mount only
// Re-run on dependency change
useEffect(() => {
fetchDevice(deviceId).then(setDevice);
}, [deviceId]); // re-runs when deviceId changes
// Event listener with cleanup
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);TSX
useRef
useRef serves two distinct purposes: holding a reference to a DOM element (pass the ref to the ref prop of a JSX element) and storing a mutable value that persists across renders without triggering a re-render. For DOM refs, always initialise with null and type as HTMLElement | null — React sets .current once the element mounts. Use ReturnType<typeof setTimeout> for timer refs to stay compatible across Node and browser environments.
// DOM ref
const inputRef = useRef<HTMLInputElement>(null);
// later: inputRef.current?.focus()
// Mutable value ref (persists across renders, no re-render on change)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
timerRef.current = setTimeout(doSomething, 1000);
// Store previous value
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => { ref.current = value; });
return ref.current;
}TSX
useMemo & useCallback
useMemo caches the result of a computation between renders; it only recalculates when a dependency changes. useCallback caches a function reference, which matters when passing callbacks to child components wrapped in React.memo — an inline arrow function creates a new reference on every render, breaking memoisation. Both hooks have a cost, so only apply them when profiling reveals a real performance issue.
// useMemo — cache expensive computation
const filteredDevices = useMemo(
() => devices.filter(d => d.status === statusFilter),
[devices, statusFilter]
);
// useCallback — cache function identity (for child prop stability)
const handleSelect = useCallback(
(id: number) => setSelectedId(id),
[] // stable reference
);
// When to use:
// useMemo → expensive .filter/.map/.sort, derived data
// useCallback → passing callbacks to memoized children
// Don't use for trivial calculations — the memoization itself has costTSX
useReducer
useReducer is the right tool when multiple pieces of state change together or when the next state depends on the current state in complex ways. The typed discriminated union for actions means TypeScript will tell you if you dispatch an unknown action type or forget a required payload. The reducer is a pure function, which makes it easy to unit test in isolation.
// Complex state with typed actions
interface DeviceState {
devices: Device[];
loading: boolean;
error: string | null;
selectedId: number | null;
}
type DeviceAction =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: Device[] }
| { type: "FETCH_ERROR"; payload: string }
| { type: "SELECT"; payload: number };
function deviceReducer(state: DeviceState, action: DeviceAction): DeviceState {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return { ...state, loading: false, devices: action.payload };
case "FETCH_ERROR":
return { ...state, loading: false, error: action.payload };
case "SELECT":
return { ...state, selectedId: action.payload };
}
}
const [state, dispatch] = useReducer(deviceReducer, {
devices: [], loading: false, error: null, selectedId: null,
});
dispatch({ type: "FETCH_START" });TSX
useContext
Initialise context with null and a typed generic so that consuming components outside the provider are caught at runtime by the guard hook. Wrapping the context value in useMemo inside the provider prevents every consumer from re-rendering whenever an unrelated piece of state changes in the provider's parent. The guard hook pattern (useAuth) centralises the null-check so consumers never need to handle it themselves.
// Create typed context
interface AuthContext {
user: User | null;
login: (creds: Credentials) => Promise<void>;
logout: () => void;
}
const AuthCtx = createContext<AuthContext | null>(null);
// Safe consumer hook (avoids null checks everywhere)
export function useAuth(): AuthContext {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
// Provider
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value: AuthContext = useMemo(() => ({
user,
login: async (creds) => { const u = await authApi(creds); setUser(u); },
logout: () => setUser(null),
}), [user]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}TSX
Component Patterns
Custom Hooks
Custom hooks are regular functions that start with use and can call other hooks. The lazy initialiser function passed to useState in useLocalStorage runs only once on mount, preventing an expensive JSON.parse on every render. The as const on the return tuple tells TypeScript to infer a tuple type rather than (T | Dispatch<SetStateAction<T>>)[], preserving the correct type for each position.
// useLocalStorage
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// useDebounce
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// useMediaQuery
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}TSX
Composition Patterns
Compound components attach sub-components as properties of the parent (e.g., Tabs.Trigger), grouping related components under one namespace while sharing internal state via context. The render prop / function-as-children pattern inverts control — the parent manages state and the consumer decides what to render with it. Both patterns avoid prop drilling without requiring a global store.
// Compound component (Headless UI style)
interface TabsProps {
children: React.ReactNode;
defaultValue: string;
}
function Tabs({ children, defaultValue }: TabsProps) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
}
Tabs.Trigger = TabTrigger;
Tabs.Content = TabContent;
// Usage
<Tabs defaultValue="devices">
<Tabs.Trigger value="devices">Devices</Tabs.Trigger>
<Tabs.Trigger value="orders">Orders</Tabs.Trigger>
<Tabs.Content value="devices"><DeviceList /></Tabs.Content>
<Tabs.Content value="orders"><OrderList /></Tabs.Content>
</Tabs>
// Render prop / Function as children
interface ToggleProps {
children: (state: { on: boolean; toggle: () => void }) => React.ReactNode;
}
function Toggle({ children }: ToggleProps) {
const [on, setOn] = useState(false);
return <>{children({ on, toggle: () => setOn(p => !p) })}</>;
}TSX
Error Boundaries
Error boundaries are class components that catch JavaScript errors anywhere in their child component tree and display a fallback UI instead of crashing the whole app. They cannot be written as function components because the componentDidCatch and getDerivedStateFromError lifecycle methods have no hook equivalent. Wrap each major page or feature section in an error boundary so one broken widget doesn't take down everything.
// Class component (still needed for error boundaries)
interface Props { children: React.ReactNode; fallback: React.ReactNode }
interface State { hasError: boolean }
class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Caught:", error, info);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<DeviceDashboard />
</ErrorBoundary>TSX
Performance
React.memo wraps a component so it only re-renders when its props change by shallow reference equality — pair it with useCallback for callback props, otherwise the callback's new reference defeats memoisation on every render. lazy + Suspense split a component into a separate JS chunk that's only loaded when first rendered, reducing initial bundle size. For lists with thousands of items, virtualisation (rendering only the visible rows) is essential.
// React.memo — skip re-render if props haven't changed
const DeviceCard = React.memo(({ device, onSelect }: DeviceCardProps) => {
return <div onClick={() => onSelect(device.id)}>{device.name}</div>;
});
// Lazy loading (code splitting)
const Settings = lazy(() => import("./pages/Settings"));
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
// Virtualization (for long lists — use @tanstack/react-virtual)
import { useVirtualizer } from "@tanstack/react-virtual";TSX
State Management
Zustand (Lightweight)
Zustand stores are defined outside React and subscribed to via a hook, which means there is no provider to wrap your tree with. Components subscribe to slices of the store using a selector function — only the selected slice changing triggers a re-render, making it efficient by default. The typed interface for the store ensures every action and state field is checked at compile time.
import { create } from "zustand";
interface DeviceStore {
devices: Device[];
selectedId: number | null;
setDevices: (devices: Device[]) => void;
select: (id: number) => void;
removeDevice: (id: number) => void;
}
const useDeviceStore = create<DeviceStore>()((set) => ({
devices: [],
selectedId: null,
setDevices: (devices) => set({ devices }),
select: (id) => set({ selectedId: id }),
removeDevice: (id) =>
set((state) => ({
devices: state.devices.filter((d) => d.id !== id),
})),
}));
// Usage in component (auto-subscribes to changes)
function DeviceList() {
const devices = useDeviceStore((s) => s.devices);
const select = useDeviceStore((s) => s.select);
// ...
}TSX
When to Use What
Local State
Form inputs, toggles, UI state. Use useState or useReducer.
Shared UI State
Theme, sidebar open, auth. Use Context or Zustand.
Server State
API data, caching, sync. Use TanStack Query (next section).
Complex Global
Large apps, many stores. Use Zustand or Redux Toolkit.
Data Fetching
TanStack Query (React Query)
Encapsulate each query in a custom hook that returns the useQuery result — this co-locates the query key, fetch function, and options, and lets you reuse the query across multiple components. The queryKey array acts as a cache key; including filter parameters in it ensures queries with different filters are cached independently. Call invalidateQueries on mutation success to mark cached data as stale and trigger a background refetch.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Typed query
function useDevices(status?: DeviceStatus) {
return useQuery({
queryKey: ["devices", { status }],
queryFn: async (): Promise<Device[]> => {
const params = status ? `?status=${status}` : "";
const res = await fetch(`/api/devices${params}`);
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
},
staleTime: 5 * 60 * 1000, // 5 min
});
}
// Usage
function DevicePage() {
const { data: devices, isLoading, error } = useDevices("online");
if (isLoading) return <Spinner />;
if (error) return <ErrorMsg error={error} />;
return <DeviceList devices={devices!} />;
}
// Mutation with cache invalidation
function useCreateDevice() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateDevice) => {
const res = await fetch("/api/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json() as Promise<Device>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["devices"] }); // refetch list
},
});
}
// Usage
const mutation = useCreateDevice();
mutation.mutate({ serial: "SN-001", name: "Till 1", restaurantId: "r42" });TSX
API Client Pattern
Centralising all fetch calls in a typed api wrapper means error handling, auth headers, and base URL logic live in one place. Each endpoint function is strongly typed with the expected response shape, so callers never need to cast or guess the return type. A custom ApiError class lets you handle HTTP errors distinctly from network failures in catch blocks.
// Typed fetch wrapper
const BASE = "/api";
async function api<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
// Typed endpoints
export const devicesApi = {
list: (status?: string) => api<Device[]>(`/devices?status=${status ?? ""}`),
get: (id: number) => api<Device>(`/devices/${id}`),
create: (data: CreateDevice) =>
api<Device>("/devices", { method: "POST", body: JSON.stringify(data) }),
delete: (id: number) =>
api<void>(`/devices/${id}`, { method: "DELETE" }),
};TypeScript
Testing
Vitest + React Testing Library
React Testing Library encourages testing components the way a user interacts with them — by querying for visible text and firing events — rather than asserting on implementation details like state or class names. vi.fn() creates a mock function whose calls you can assert on. For async tests, use waitFor to poll until an assertion passes, which handles components that update state after a fetch or timer.
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
describe("DeviceCard", () => {
const mockDevice: Device = {
id: 1, serial: "SN-001", name: "Till 1",
status: "online", restaurantId: "r1", createdAt: new Date(),
};
it("renders device name", () => {
render(<DeviceCard device={mockDevice} onSelect={vi.fn()} />);
expect(screen.getByText("Till 1")).toBeInTheDocument();
});
it("calls onSelect when clicked", () => {
const onSelect = vi.fn();
render(<DeviceCard device={mockDevice} onSelect={onSelect} />);
fireEvent.click(screen.getByText("Till 1"));
expect(onSelect).toHaveBeenCalledWith(1);
});
});
// Async testing
it("fetches and displays devices", async () => {
render(<DevicePage />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Till 1")).toBeInTheDocument();
});
});TSX
# Setup
$ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
# Run
$ npx vitest # watch mode
$ npx vitest run # single runQuick Reference Table
| Hook / Tool | Use Case | Gotcha |
|---|---|---|
useState | Simple local state | Set with function form for prev-dependent updates |
useEffect | Side effects, subscriptions | Always return cleanup; don't forget dependencies |
useRef | DOM refs, mutable values | Changing .current doesn't trigger re-render |
useMemo | Expensive computations | Don't overuse — memoisation has its own cost |
useCallback | Stable function references | Only useful when passing to memoised children |
useReducer | Complex state transitions | Overkill for simple boolean/string state |
useContext | Shared state (theme, auth) | All consumers re-render on any context change |
React.memo | Skip re-renders | Only shallow-compares props by default |
useQuery | Server state / caching | Set staleTime to avoid refetch storms |
useMutation | Create / update / delete | Invalidate queries on success for fresh data |