Guided React Project
Before you start
You need: all of Articles 01–09, and a running API from Track 10.
Time: 12–16 hours across two weeks.
Goal
Demonstrate that you can build a maintainable React application over a secured API — routed, lazy-loaded, validated, accessible, with every request state handled and no effect leaks.
Assignment
Build the NexCoding School Portal frontend against the Web API from the ASP.NET Core track.
| Deliverable | Contents |
|---|---|
src/api/ | Typed client and per-resource services |
src/features/ | One folder per area, lazy-loaded |
src/components/ | Shared presentational components |
src/hooks/ | Reusable hooks |
src/router.tsx | Route table |
README.md | How to run it against the API |
DECISIONS.md | Choices, with reasons |
Required screens
| Route | Behaviour |
|---|---|
/login | Anonymous, from redirect, one message for any credential failure |
/students | Search, class filter, paging — all four states |
/students/new | Validated form |
/students/:publicId | Layout with Overview, Results and Fees child routes |
/students/:publicId/edit | Pre-filled, unsaved-changes prompt |
/fees | Outstanding fees, Admin and Staff only |
/exams/:examId/results | Bulk marks entry |
* | Not found |
Non-negotiable requirements
- Every feature area lazy-loaded; verified in the Network tab
schoolIdnever sent from the client- Filters, paging and sort in the URL, so refresh and shared links work
- Every list handles loading, success, empty and error
- Search debounced, every fetch cancelled with
AbortController - No effect leaks — every listener, timer and request cleaned up
- Stable keys on every list
- Token attached in one place, 401 handled centrally
- Server 400
errorsmapped to individual fields - Fully keyboard operable, focus visible, state changes announced
- Works at 320px and at 200% zoom
npm run buildclean,strict: true, noany
Worked example: the list screen
Where most of the track's requirements meet.
type ListState =
| { status: 'loading' }
| { status: 'success'; result: PagedResult<Student> }
| { status: 'empty'; term: string }
| { status: 'error'; message: string };
export function useStudents(term: string, className: string, page: number) {
const [state, setState] = useState<ListState>({ status: 'loading' });
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
studentApi.search({ term, className, page, pageSize: 20 }, controller.signal)
.then(result => setState(
result.items.length === 0
? { status: 'empty', term }
: { status: 'success', result }))
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
setState({
status: 'error',
message: err instanceof ApiError && err.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.'
});
});
return () => controller.abort();
}, [term, className, page, reloadToken]);
const reload = useCallback(() => setReloadToken(prev => prev + 1), []);
return { state, reload };
}
export function StudentListPage() {
const [searchParams, setSearchParams] = useSearchParams();
const term = searchParams.get('term') ?? '';
const className = searchParams.get('className') ?? '';
const page = Number(searchParams.get('page') ?? '1');
const debouncedTerm = useDebounce(term, 300);
const { state, reload } = useStudents(debouncedTerm, className, page);
function updateSearch(next: string) {
setSearchParams(prev => {
const params = new URLSearchParams(prev);
next ? params.set('term', next) : params.delete('term');
params.set('page', '1');
return params;
}, { replace: true });
}
const announcement =
state.status === 'loading' ? 'Loading students' :
state.status === 'empty' ? 'No students found' :
state.status === 'error' ? state.message :
`${state.result.totalCount} students found`;
return (
<>
<h1>Students</h1>
<SearchBox value={term} onChange={updateSearch} />
<p role="status" aria-live="polite" className="visually-hidden">{announcement}</p>
{state.status === 'loading' && <p className="muted">Loading students…</p>}
{state.status === 'empty' && (
<p className="muted">No students match “{state.term}”.</p>
)}
{state.status === 'error' && (
<div role="alert">
<p className="error">{state.message}</p>
<button type="button" onClick={reload}>Try again</button>
</div>
)}
{state.status === 'success' && (
<>
<StudentTable result={state.result} />
<Pager result={state.result} onPageChange={p => updatePage(p)} />
</>
)}
</>
);
}
Six decisions a reviewer will check:
| Detail | What breaks without it |
|---|---|
useDebounce | Four requests for "Ravi" |
AbortController + cleanup | Out-of-order results — the list shows "Rav" |
AbortError excluded | An error shown for a request you cancelled |
| URL state | Filters lost on refresh; links not shareable |
{ replace: true } | Every keystroke a history entry; Back unusable |
aria-live | State changes silent to a screen reader |
The error state has a retry button. An error with no way forward is a dead end.
The discriminated union means TypeScript enforces every branch, and state.term is only reachable where it exists.
Worked example: the form
export function StudentFormPage() {
const { publicId } = useParams<{ publicId: string }>();
const navigate = useNavigate();
const { values, errors, isValid, handleChange, handleBlur, errorFor,
setSubmitted, setServerErrors, setValues } = useForm(emptyStudent, validate);
const [saving, setSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (!publicId) return;
const controller = new AbortController();
studentApi.getByPublicId(publicId, controller.signal)
.then(student => {
setValues(toFormValues(student));
setDirty(false);
})
.catch(() => { /* handled by the error boundary */ });
return () => controller.abort();
}, [publicId, setValues]);
useEffect(() => {
function warn(event: BeforeUnloadEvent) {
if (dirty) event.preventDefault();
}
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setSubmitted(true);
setServerError(null);
setServerErrors({});
if (!isValid) {
document.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
return;
}
setSaving(true);
try {
publicId
? await studentApi.update(publicId, values)
: await studentApi.create(values);
setDirty(false);
navigate('/students?saved=1', { replace: true });
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
const body = err.body as { errors?: Record<string, string[]> };
if (body.errors) {
setServerErrors(mapServerErrors(body.errors));
return;
}
}
setServerError(err instanceof ApiError && err.status === 409
? 'This roll number is already in use.'
: 'Could not save. Please try again.');
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
{serverError && <p className="error" role="alert">{serverError}</p>}
{/* fields using errorFor(...) */}
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={() => navigate('/students')}>Cancel</button>
</form>
);
}
Six things a reviewer will check:
| Detail | What breaks without it |
|---|---|
AbortController on the load | A stale record overwrites a newer one |
setDirty(false) after load | The unsaved-changes prompt fires immediately |
setDirty(false) before navigate | It prompts after a successful save |
finally clears saving | The button stays disabled after an error |
disabled={saving} | A double-click creates two students |
| Focus the first invalid field | A keyboard user has to hunt for the problem |
navigate(..., { replace: true }) means Back does not return to a form already submitted.
Worked example: structure
src/
├── main.tsx
├── router.tsx
├── api/
│ ├── client.ts apiRequest, ApiError, token attachment, 401 handling
│ ├── students.ts
│ ├── fees.ts
│ └── exams.ts
├── features/
│ ├── auth/ AuthContext, AuthProvider, LoginPage, RequireAuth, RequireRole
│ ├── students/ list, shell, form, useStudents, StudentTable
│ ├── exams/ results entry
│ └── fees/ outstanding report
├── components/ Pager, EmptyState, ErrorMessage, ConfirmDialog, Field
├── hooks/ useDebounce, useForm, useLocalStorage
├── types/ student.ts, paged-result.ts
└── styles/ index.css with tokens
// router.tsx
const StudentListPage = lazy(() => import('./features/students/StudentListPage'));
const FeeReportPage = lazy(() => import('./features/fees/FeeReportPage'));
export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Navigate to="/students" replace /> },
{ path: 'login', element: <LoginPage /> },
{
path: 'students',
element: <RequireAuth><Outlet /></RequireAuth>,
children: [
{ index: true, element: <StudentListPage /> },
{ path: 'new', element: <StudentFormPage /> },
{ path: ':publicId', element: <StudentShell />, children: [ /* nested routes */ ] },
{ path: ':publicId/edit', element: <StudentFormPage /> }
]
},
{
path: 'fees',
element: <RequireRole roles={['Admin', 'Staff']}><Outlet /></RequireRole>,
children: [{ index: true, element: <FeeReportPage /> }]
},
{ path: '*', element: <NotFoundPage /> }
]
}
]);
errorElement keeps the layout and navigation when a page fails, instead of a blank white screen.
Submission template
DECISIONS.md
Structure:
Folder layout and what belongs where:
Which features are lazy-loaded, and the chunk sizes:
State:
Where each piece of state lives, against the five-place list:
Any context, and why it is split the way it is:
What lives in the URL and why:
Server state approach — manual hooks or a query library:
Routing:
Route table and where auth checks sit:
What uses `replace` and why:
Forms:
Validation approach, and where the rules live:
How server 400 errors reach individual fields:
Unsaved-changes handling:
API integration:
Client design, token attachment, 401 handling:
How each of network failure, 400, 401, 403, 404, 409, 500 is handled:
Debounce and cancellation:
The four states, per list screen:
Effects:
Every effect, its dependencies, and its cleanup:
Accessibility:
Keyboard walkthrough result:
Screen-reader test result:
Live regions used:
Focus management on validation failure and dialog close:
Security boundary:
What protected routes and hidden UI actually achieve:
What the server enforces independently:
Performance:
Any memo/useMemo/useCallback, and the measurement that justified it:
Bundle size, initial and lazy:
Deliberately not done, and why:
Verification
Runs from a clean clone. npm install, set VITE_API_URL, npm run dev. Every screen works.
Lazy loading. Navigate to each feature and confirm a new chunk downloads. npm run build and record the initial bundle size.
No schoolId from the client. Search every request in the Network tab. It must appear nowhere. Sign in as a school 2 user and confirm you cannot see school 1's data.
Four states. Force each on every list: a valid search, one matching nothing, the API stopped, and Slow 3G throttling.
No request races. Throttle to Slow 3G and click quickly between two students. Confirm the correct one renders and earlier requests show as cancelled.
No effect leaks. Navigate into and out of a screen ten times, then trigger the event its listener handles. Confirm the handler fires once.
Keys are stable. Add a text input to each row, type into one, then sort the list. Values must follow their rows.
Refresh safety. Search, filter, page 3, refresh. Confirm the same view. Copy the URL into a new tab and confirm it opens identically.
401 handling. Clear the token mid-session and act. Confirm the redirect to login, and that signing in returns you to where you were.
Partial update preserved. Edit only the name and save. Confirm dateOfBirth, parentPhone and the rest are unchanged in the database.
Server errors reach fields. Submit a duplicate roll number and confirm the message appears beside that field.
Double-submit prevented. Double-click Save and confirm one student is created.
Keyboard only. Put the mouse away and complete a full create-student flow. Every control reachable, focus always visible, no trap, validation failure moves focus to the first invalid field, closing a dialog returns focus to its trigger.
Screen reader. NVDA or VoiceOver. Confirm form labels announced, table headers read with cells, list state changes announced, errors read.
320px and 200% zoom. Every screen usable, no horizontal page scroll.
Security boundary. Edit the token payload in DevTools to change your role. Confirm the UI changes and the API still refuses.
Production build. npm run build && npm run preview. Refresh on a deep link and confirm the SPA fallback. Search dist/ and confirm no secret is present.
Build clean. No TypeScript errors, strict: true, no any, no lint warnings.
AI practice
Three AI exercises from this track's syllabus. Do each after the project works, and apply Track 18's discipline — every answer is a hypothesis until you have run it.
- Ask AI to explain hook dependencies. Paste a
useEffectthat fetches a student by id and ask what happens if the dependency array is empty, missing, or contains an object. Then test each. An empty array leaves stale data on screen when the id changes; a missing array causes an infinite render loop — and both are silent until you look. - Review generated code for unnecessary state. Ask for a student list with search and filtering. Check whether anything in
useStatecould be derived from existing state during render instead. Generated components frequently store a filtered copy that then drifts out of step with the source. - Use AI to draft tests, then validate them. Ask for tests for the fee payment form, then confirm each fails when you comment out the code it covers. Also check whether the absent-student and error states are tested at all — generated tests cover the happy path and stop.
Exercise 1 is the calibration one. Dependency-array bugs produce no error, and a stale student record on screen looks exactly like a slow network.
Track 18 — Reviewing AI-generated code — has the full checklist.
Self-assessment
Your submission is complete when someone can clone it, point it at the API, use every screen with a keyboard alone, and read DECISIONS.md to see which choices were deliberate.
Four specific tests of quality:
- Does the search survive Slow 3G with rapid typing? This is the race
AbortControllerexists for, and it is invisible on a fast connection. - Does the handler fire once after ten navigations? A leak is silent — the application just gets slower — and only this test finds it.
- Do all four states appear, including empty? A blank screen for "no results" is indistinguishable from a bug.
- Does
DECISIONS.mdstate where protected routes stop being security? Explaining that a hidden button is interface, not access control, is what separates understanding React from copying it.
Track completion criteria
You can build reusable React interfaces, use forms, hooks and routing, integrate ASP.NET Core REST APIs, and debug common React problems.
Specifically, you can:
- Set up a project and structure it by feature
- Write components with typed props and stable list keys
- Manage state immutably and explain why a component re-rendered
- Write effects with correct dependencies and cleanup
- Build validated, accessible forms with server-error mapping
- Build a lazy-loaded routed application whose URLs are shareable
- Call an API handling every status code and all four states
- Prevent request races with debounce and cancellation
- Choose where each piece of state belongs
- Build an authentication flow and state where the security boundary is
- Ship a production build that works when deployed
The syllabus recommends Track 01 — Microsoft .NET Full Stack Guided Path or Track 16 — Git & Source Control next.