Skip to main content
Published / updated

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.

DeliverableContents
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.tsxRoute table
README.mdHow to run it against the API
DECISIONS.mdChoices, with reasons

Required screens

RouteBehaviour
/loginAnonymous, from redirect, one message for any credential failure
/studentsSearch, class filter, paging — all four states
/students/newValidated form
/students/:publicIdLayout with Overview, Results and Fees child routes
/students/:publicId/editPre-filled, unsaved-changes prompt
/feesOutstanding fees, Admin and Staff only
/exams/:examId/resultsBulk marks entry
*Not found

Non-negotiable requirements

  • Every feature area lazy-loaded; verified in the Network tab
  • schoolId never 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 errors mapped to individual fields
  • Fully keyboard operable, focus visible, state changes announced
  • Works at 320px and at 200% zoom
  • npm run build clean, strict: true, no any

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:

DetailWhat breaks without it
useDebounceFour requests for "Ravi"
AbortController + cleanupOut-of-order results — the list shows "Rav"
AbortError excludedAn error shown for a request you cancelled
URL stateFilters lost on refresh; links not shareable
{ replace: true }Every keystroke a history entry; Back unusable
aria-liveState 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:

DetailWhat breaks without it
AbortController on the loadA stale record overwrites a newer one
setDirty(false) after loadThe unsaved-changes prompt fires immediately
setDirty(false) before navigateIt prompts after a successful save
finally clears savingThe button stays disabled after an error
disabled={saving}A double-click creates two students
Focus the first invalid fieldA 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.

  1. Ask AI to explain hook dependencies. Paste a useEffect that 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.
  2. Review generated code for unnecessary state. Ask for a student list with search and filtering. Check whether anything in useState could be derived from existing state during render instead. Generated components frequently store a filtered copy that then drifts out of step with the source.
  3. 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 AbortController exists 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.md state 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.