Practice Questions
1. What is client-side routing, and how does it differ from traditional server-side navigation? What role does the <Outlet /> component play in a layout?
Solution
In traditional navigation, clicking a link causes the browser to request a new HTML document from the server, resulting in a full page reload. Client-side routing intercepts navigation in the browser and swaps only the parts of the UI that change, while updating the URL so bookmarks and the back button still work. The page never fully reloads.
<Outlet /> is a placeholder component used in a layout (root or nested). It renders whichever child route matches the current URL. The surrounding layout (header, nav, footer) stays mounted, and only the <Outlet /> content changes as the user navigates.
2. Explain the difference between a controlled and an uncontrolled input in React. Convert the following uncontrolled input into a controlled one:
function SearchBox() {
return <input type="text" placeholder="Search..." />;
}
Solution
An uncontrolled input lets the browser (the DOM) manage its own value internally. You only read the value when you need it (e.g., on form submission). A controlled input has its value driven by React state via the value prop, and every change goes through an onChange handler that updates that state. React becomes the single source of truth.
import { useState } from "react";
function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
The two parts that matter are the value prop tied to state and the onChange handler that updates that state on every keystroke.
3. Write a React component called ContactForm with controlled inputs for name and email. On submission, it should call a callback prop onSubmit with an object { name, email }, then reset both fields. Prevent the default form submission behavior.
Solution
import { useState, type FormEvent } from "react";
type ContactData = { name: string; email: string };
function ContactForm({ onSubmit }: { onSubmit: (data: ContactData) => void }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
onSubmit({ name: name.trim(), email: email.trim() });
setName("");
setEmail("");
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
}
Key points: e.preventDefault() stops the browser’s default form submission (which would cause a page reload). Setting state back to "" clears the controlled inputs immediately because React re-renders with the new (empty) values.
4. When is useReducer preferable to useState? Describe what a reducer function receives and returns.
Solution
useReducer is preferable when state update logic is complex or involves multiple related operations (add, delete, edit) on the same piece of state. With useState, these updates are scattered across multiple handler functions. With useReducer, all state transitions are centralized in a single pure function, making the logic easier to read, test, and extend.
A reducer function receives two arguments: the current state and an action (a plain object describing what happened, typically with a type field). It returns the new state. The reducer must be a pure function. It has no side effects and does not mutate the existing state. It always returns a new value.
const [state, dispatch] = useReducer(reducerFn, initialState);
dispatch({ type: "deleted", id: "123" });
5. Given the following action type, write a reducer function that manages an array of Item objects. Include an assertNever safety check.
type Item = { id: string; text: string };
type ItemAction =
| { type: "added"; item: Item }
| { type: "deleted"; id: string }
| { type: "updated"; id: string; text: string };
Solution
function assertNever(value: never): never {
throw new Error(`Unexpected action: ${JSON.stringify(value)}`);
}
function itemsReducer(items: Item[], action: ItemAction): Item[] {
switch (action.type) {
case "added":
return [...items, action.item];
case "deleted":
return items.filter((item) => item.id !== action.id);
case "updated":
return items.map((item) =>
item.id === action.id ? { ...item, text: action.text } : item,
);
}
return assertNever(action);
}
The ItemAction is a discriminated union – TypeScript narrows the type inside each case branch. The assertNever call at the bottom ensures that if a new action type is added to the union later, TypeScript will produce a compile-time error if the corresponding case is missing, because action will no longer be of type never.
6. What is a discriminated union in TypeScript, and why is it useful for defining reducer actions? What does the assertNever pattern accomplish?
Solution
A discriminated union is a union of object types that share a common literal field (the “discriminant”) – for example, type. TypeScript uses that field to narrow the type inside switch or if branches. When action.type is "added", TypeScript knows the action has an item property; when it is "deleted", it knows the action has an id property.
This makes reducer actions fully type-safe: you cannot access properties that do not belong to the current action variant, and autocomplete works correctly in each branch.
The assertNever pattern is a compile-time safety check. After handling every variant in a switch, the value should be of type never (no possible types remain). If you add a new variant to the union but forget to handle it, TypeScript flags assertNever(action) as an error because action is no longer never. At runtime, if somehow an unrecognized action reaches that point, it throws an informative error.
7. A reducer currently manages a flat list of Task objects. You need to scale it to manage a Project[] where each project contains a tasks: Task[] array. Rewrite the "task_added" action handler so the reducer finds the correct project and appends the task.
type Task = { id: string; title: string };
type Project = { id: string; name: string; tasks: Task[] };
Solution
type ProjectAction = { type: "task_added"; projectId: string; task: Task };
// ... other actions
function projectsReducer(
projects: Project[],
action: ProjectAction,
): Project[] {
switch (action.type) {
case "task_added":
return projects.map((project) =>
project.id === action.projectId
? { ...project, tasks: [...project.tasks, action.task] }
: project,
);
// ... other cases
}
}
The key change from a flat reducer is that each action now includes a projectId so the reducer can locate the correct parent. The .map() call creates a new array, replacing only the matching project with a shallow copy that includes the new task appended to its tasks array. All other projects are returned unchanged. This avoids mutating the existing state.
8. Explain how useReducer’s lazy initializer works. Given the following code, what does React do with the second and third arguments?
const [items, dispatch] = useReducer(itemsReducer, null, loadItems);
Solution
When useReducer receives a third argument (a function), React uses it as a lazy initializer. Instead of using the second argument directly as the initial state, React calls loadItems(null) once on the first render, and the return value becomes the initial state.
- Second argument (
null): the initializer input – passed as the argument toloadItems. It is not the state itself. - Third argument (
loadItems): the initializer function – called once on mount to compute the initial state.
This is useful for expensive initial computations like reading from localStorage. The function runs only once, not on every render, and it keeps the initialization logic out of the component body.
9. Write a useEffect that syncs a notes state array to localStorage whenever it changes. What would happen if you omitted the dependency array?
Solution
useEffect(() => {
localStorage.setItem("my-notes", JSON.stringify(notes));
}, [notes]);
The dependency array [notes] tells React to run the effect only when notes changes. On every state update that produces a new notes array, the effect fires and writes the latest data to localStorage.
If you omitted the dependency array entirely:
useEffect(() => {
localStorage.setItem("my-notes", JSON.stringify(notes));
});
The effect would run after every render, not just when notes changes. Any unrelated state update or re-render would trigger an unnecessary write to localStorage. The data would still be correct, because notes is the current value at that point, but the extra writes are wasted work and can hurt performance.
10. What is a custom hook in React? What naming convention must it follow, and why? Write a custom hook called useToggle that manages a boolean state and returns the current value along with a toggle function.
Solution
A custom hook is a JavaScript/TypeScript function that calls other React hooks (like useState, useEffect, useReducer). It lets you extract reusable stateful logic out of components so multiple components can share the same behavior without duplicating code.
Custom hooks must start with the prefix use (e.g., useToggle, useBoards). This naming convention is required because React uses it to enforce the Rules of Hooks (hooks can only be called at the top level, not inside conditions or loops). The linter relies on the use prefix to detect hook calls.
import { useState } from "react";
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
function toggle() {
setValue((prev) => !prev);
}
return { value, toggle };
}
export { useToggle };
Usage:
function DarkModeSwitch() {
const { value: isDark, toggle } = useToggle(false);
return (
<button onClick={toggle}>{isDark ? "Dark Mode" : "Light Mode"}</button>
);
}
11. Consider a custom hook that wraps useReducer with localStorage persistence. It returns { items, addItem, removeItem } instead of exposing dispatch directly. Why is this a better API for consumers of the hook?
Solution
Returning named functions like addItem and removeItem instead of dispatch provides several benefits:
- Simpler API: Consumers do not need to know about action types, discriminated unions, or the structure of action objects. They call
addItem(item)instead ofdispatch({ type: "item_added", item }). - Encapsulation: The reducer implementation, action types, and persistence logic (localStorage reads/writes) are hidden inside the hook. If you restructure the actions or change how persistence works, consumers do not need to change.
- Type safety at the boundary: The hook’s public interface is a set of clearly typed functions. Consumers cannot dispatch invalid actions because they do not have access to
dispatch. - Easier to extend: You can add a new operation by adding a new action type to the reducer and exposing a new function from the hook, without affecting existing consumers.
12. You have two route components that both need to read and modify a shared collection stored in localStorage. Each creates its own instance of a custom hook that reads from localStorage on mount and writes on every change. Explain why this works when navigating between routes, and identify a scenario where this approach would break down.
Solution
It works because client-side routing causes components to unmount and remount when you navigate between routes. When you leave Route A, it unmounts (and its state is discarded). When Route B mounts, the custom hook runs its initializer, which reads the latest data from localStorage. Since Route A writes its changes to localStorage on every state update (via useEffect), Route B always sees fresh data on mount.
This approach breaks down when two components using the hook are mounted at the same time (e.g., a sidebar and a main content area, or deeply nested components on the same page). Each component creates its own independent instance of the hook with its own state. If Component A adds an item, Component B’s state is not updated – it still holds the stale data from when it mounted. They are not sharing state. Each one manages its own separate copy.
For in-memory state shared across components that are mounted at the same time, use React Context. Context provides a single state instance that all consumers read from.