Custom hooks are the primary mechanism for extracting and reusing stateful
logic in React. A custom hook is simply a function whose name starts with
use and that may call other hooks inside it. The hook extracts behaviour — not
UI.
In this challenge you will take three inline useEffect calls from
MainContent.tsx and move them into dedicated custom hooks. The visual output
of the app will be identical before and after; the only change is how the logic
is organised.
- Understand the difference between extracting a component (reusing UI) and extracting a hook (reusing logic).
- Name custom hooks with the
useprefix so React can enforce the Rules of Hooks. - Declare explicit TypeScript return types for custom hooks.
- Understand that hooks derive state and values rather than storing redundant copies in state.
- Recognise the rules of hooks: only call hooks at the top level, only call hooks inside React functions.
The start/ directory is the solution from Challenge 08. It already has a fully
working app with three inline useEffect calls inside MainContent.tsx:
- A document-title effect that depends on
projects.length. - A keyboard-shortcut effect with cleanup (
Cmd/Ctrl+Kopens the modal). - A toast-auto-dismiss effect that depends on
toastMessage.
The src/hooks/ directory exists but is empty. Your job is to extract the first
two effects (and filter state) into custom hooks in that directory.
Run the start app to verify it works before you begin:
cd start
npm install
npm run devWork inside start/src/. Create new files in src/hooks/ and update
src/components/MainContent.tsx.
Create src/hooks/useDocumentTitle.ts.
export function useDocumentTitle(title: string): void- Accepts a
titlestring. - Runs
document.title = titleinside auseEffect. - The dependency array should contain
titleso the title updates whenever the argument changes. - Return type is
void(explicit annotation required).
Call it from MainContent like this:
useDocumentTitle(
projects.length > 0 ? 'Projects | TaskFlow' : 'Get Started | TaskFlow'
);Create src/hooks/useKeyboardShortcut.ts.
export function useKeyboardShortcut(
key: string,
modifier: 'meta' | 'ctrl',
callback: () => void
): void- Accepts the key string (e.g.
"k"), a modifier ("meta"or"ctrl"), and a callback. - Registers a
keydownlistener onwindowinside auseEffect. - The listener checks the correct modifier key and calls
callback()when the shortcut fires. - Returns a cleanup function that removes the listener.
- Dependency array:
[key, modifier, callback]. - Return type is
void(explicit annotation required).
Call it from MainContent like this:
useKeyboardShortcut('k', 'meta', () => setShowForm(true));Create src/hooks/useProjectFilters.ts.
interface UseProjectFiltersReturn {
filteredProjects: ProjectCardProps[];
activeFilter: ProjectStatus | 'all';
setFilter: (filter: ProjectStatus | 'all') => void;
statusCounts: Record<ProjectStatus | 'all', number>;
}
export function useProjectFilters(
projects: ProjectCardProps[]
): UseProjectFiltersReturn- Owns the
activeFilterstate (initially'all'). - Derives
filteredProjectsfromprojectsandactiveFilter— no seconduseState. - Derives
statusCountsby counting each status inprojects— not stored in state. - Returns all four values in a single object.
Call it from MainContent like this:
const { filteredProjects, activeFilter, setFilter, statusCounts } =
useProjectFilters(projects);Then pass activeFilter and setFilter to StatusFilter where previously
activeFilter state and setActiveFilter were used.
-
src/hooks/useDocumentTitle.tsexists and exportsuseDocumentTitle -
src/hooks/useKeyboardShortcut.tsexists and exportsuseKeyboardShortcut -
src/hooks/useProjectFilters.tsexists and exportsuseProjectFilters -
MainContent.tsxhas no inlineuseEffectcalls for title or keyboard shortcut - Filter state is no longer in
MainContent— it lives inuseProjectFilters - The app still works identically: filtering, modal, keyboard shortcut, toast
-
npm run buildpasses with zero TypeScript errors - All hooks have explicit TypeScript return type annotations
// Before — inline in a component:
useEffect(() => {
document.title = title;
}, [title]);
// After — extracted into a hook:
export function useDocumentTitle(title: string): void {
useEffect(() => {
document.title = title;
}, [title]);
}The only requirement is that the function name starts with use. React uses
this convention to apply the Rules of Hooks linter checks.
Always annotate the return type explicitly. For a hook that returns multiple values, define an interface:
interface UseProjectFiltersReturn {
filteredProjects: ProjectCardProps[];
activeFilter: ProjectStatus | 'all';
setFilter: (filter: ProjectStatus | 'all') => void;
statusCounts: Record<ProjectStatus | 'all', number>;
}filteredProjects and statusCounts are derived from projects and
activeFilter on every render. Storing them in state would introduce a
synchronisation problem: two sources of truth that could get out of sync.
// Correct — derived during render
const filteredProjects = activeFilter === 'all'
? projects
: projects.filter(p => p.status === activeFilter);
// Wrong — creates a stale-state bug
const [filteredProjects, setFilteredProjects] = useState(projects);- Only call hooks at the top level. Do not call hooks inside conditions, loops, or nested functions.
- Only call hooks from React functions. That means function components and other custom hooks — not plain utility functions.
These rules exist because React identifies hooks by their call order. If the order changes between renders, React cannot correctly match state to the hook that owns it.
- Add a
useToasthook that encapsulatestoastMessagestate and the auto-dismiss effect. It should return{ toastMessage, showToast }. - Modify
useKeyboardShortcutto accept an array of shortcuts instead of a single key, so multiple shortcuts can be registered with one call. - Extract a
useLocalStoragehook that persists the active filter tolocalStorageand restores it on page load.
cd start
npm install
npm run devNavigate to http://localhost:5173 in your browser.
To check the TypeScript build:
npm run build