React Tutorial: Build a Production-Ready Feature Slice

Most React tutorials stop at “it works on my machine.” In real products, that is the start, not the finish. A production-ready feature slice is a small, end-to-end unit of value (a user journey) that is safe to ship, easy to operate, and hard to break.
This tutorial shows one pragmatic way to build a feature slice with:
- Clear module boundaries (so the codebase stays maintainable as teams grow)
- Predictable server state (loading, error, caching, retries)
- Form validation and failure handling
- Testing that protects the slice during refactors
- Small operability hooks (so production issues are diagnosable)
If you want the deeper architectural reasoning behind feature-first modularity, see Wolf-Tech’s guide on React front end architecture for product teams.
What we are building: “Invite Team Member” slice
We will implement a single feature slice for a typical B2B app:
- A page that lists current team members
- A form to invite a new member by email and role
- Server validation errors displayed correctly
- A predictable UX for loading, empty, and error states
This is intentionally small. Production readiness is not about how many features you cram in, it is about how safely you can ship and operate one thin vertical slice.
Assumptions and tooling (a practical default stack)
You can adapt this to Next.js, Remix, or a pure SPA. To keep the tutorial framework-neutral, we assume:
- React with TypeScript
- React Router (or any router with a route-level component)
- TanStack Query for server state
- React Hook Form + Zod for forms and validation
- Testing Library (component behavior) and Playwright (happy-path E2E)
References:
- React docs: react.dev
- TanStack Query: tanstack.com/query
If you want an opinionated “production toolkit” checklist, Wolf-Tech has a dedicated guide on React tools for production UIs.
Define the slice boundary (the rule that prevents spaghetti)
A feature slice should be able to answer these questions without scanning the whole repo:
- Where is the route entry for this capability?
- Where are its types and API contract?
- Where is its data access logic?
- Where are its UI components?
- Where are its tests?
A simple feature-first layout:
src/
features/
teamInvites/
api/
teamApi.ts
teamApi.schemas.ts
hooks/
useMembers.ts
useInviteMember.ts
ui/
InviteMemberForm.tsx
MembersList.tsx
TeamMembersPage.tsx
__tests__/
InviteMemberForm.test.tsx
MembersList.test.tsx
This slice owns its UI and contracts. Shared primitives (button, modal, fetch wrapper, telemetry client) live elsewhere, but business logic does not leak into “shared.”

Start with contracts: types that survive refactors
Even if you do not run a “contract-first” process org-wide, doing it inside a slice pays back quickly.
Create Zod schemas for runtime validation (and infer TypeScript types from them).
// src/features/teamInvites/api/teamApi.schemas.ts
import { z } from "zod";
export const MemberSchema = z.object({
id: z.string(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
status: z.enum(["active", "invited"]).default("active"),
});
export const MembersResponseSchema = z.object({
items: z.array(MemberSchema),
});
export type Member = z.infer<typeof MemberSchema>;
export type MembersResponse = z.infer<typeof MembersResponseSchema>;
export const InviteMemberRequestSchema = z.object({
email: z.string().email(),
role: z.enum(["admin", "member"]),
});
export type InviteMemberRequest = z.infer<typeof InviteMemberRequestSchema>;
Why this matters in production:
- You catch backend surprises early (missing fields, unexpected nulls)
- You can safely evolve UI code because the boundary is explicit
- Your tests can validate against the same schema
Add a minimal fetch wrapper (with predictable failures)
Production bugs often come from inconsistent error handling. Standardize one small behavior: every API call either returns validated data or throws a typed error.
// src/shared/http/httpClient.ts
export class HttpError extends Error {
status: number;
details?: unknown;
constructor(message: string, status: number, details?: unknown) {
super(message);
this.status = status;
this.details = details;
}
}
export async function httpJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const res = await fetch(input, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;
if (!res.ok) {
throw new HttpError("Request failed", res.status, data);
}
return data as T;
}
This is intentionally small. Do not overbuild a client abstraction until you have a clear need.
Implement the slice API module
// src/features/teamInvites/api/teamApi.ts
import { httpJson } from "../../shared/http/httpClient";
import {
InviteMemberRequest,
InviteMemberRequestSchema,
MembersResponse,
MembersResponseSchema,
} from "./teamApi.schemas";
function parse<T>(schema: { parse: (x: unknown) => T }, value: unknown): T {
return schema.parse(value);
}
export async function getMembers(): Promise<MembersResponse> {
const raw = await httpJson<unknown>("/api/team/members");
return parse(MembersResponseSchema, raw);
}
export async function inviteMember(req: InviteMemberRequest): Promise<void> {
const safe = InviteMemberRequestSchema.parse(req);
await httpJson("/api/team/invite", {
method: "POST",
body: JSON.stringify(safe),
});
}
At this point, the slice has an explicit contract and predictable errors.
Add server-state hooks (cache, loading, retries)
Server state is not “component state.” Treat it as its own category.
// src/features/teamInvites/hooks/useMembers.ts
import { useQuery } from "@tanstack/react-query";
import { getMembers } from "../api/teamApi";
export function useMembers() {
return useQuery({
queryKey: ["team", "members"],
queryFn: getMembers,
staleTime: 30_000,
});
}
For the mutation:
// src/features/teamInvites/hooks/useInviteMember.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { inviteMember } from "../api/teamApi";
export function useInviteMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: inviteMember,
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["team", "members"] });
},
});
}
This gives you:
- A consistent loading model
- A consistent error model
- A predictable refresh mechanism after mutation
Build the UI with explicit states (loading, empty, error)
A production UI does not assume “happy path.” It renders explicit states.
// src/features/teamInvites/ui/MembersList.tsx
import * as React from "react";
import { useMembers } from "../hooks/useMembers";
export function MembersList() {
const { data, isLoading, isError, error } = useMembers();
if (isLoading) {
return <div aria-busy="true">Loading members…</div>;
}
if (isError) {
return (
<div role="alert">
Failed to load members. {(error as Error).message}
</div>
);
}
const items = data?.items ?? [];
if (items.length === 0) {
return <p>No team members yet.</p>;
}
return (
<ul>
{items.map((m) => (
<li key={m.id}>
<strong>{m.email}</strong> ({m.role}, {m.status})
</li>
))}
</ul>
);
}
Accessibility notes that tend to matter in real apps:
aria-busyfor loading regionsrole="alert"for error messages that should be announced- Avoid “spinner only” states without text
Add the form (validation + server errors)
Client-side validation prevents basic input waste, but you still need server-side errors (duplicate invite, forbidden role, domain restriction).
// src/features/teamInvites/ui/InviteMemberForm.tsx
import * as React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteMemberRequest, InviteMemberRequestSchema } from "../api/teamApi.schemas";
import { useInviteMember } from "../hooks/useInviteMember";
import { HttpError } from "../../shared/http/httpClient";
export function InviteMemberForm() {
const invite = useInviteMember();
const form = useForm<InviteMemberRequest>({
resolver: zodResolver(InviteMemberRequestSchema),
defaultValues: { email: "", role: "member" },
mode: "onBlur",
});
const [serverError, setServerError] = React.useState<string | null>(null);
async function onSubmit(values: InviteMemberRequest) {
setServerError(null);
try {
await invite.mutateAsync(values);
form.reset({ email: "", role: "member" });
} catch (e) {
if (e instanceof HttpError && e.status === 409) {
setServerError("That email already has a pending invite.");
return;
}
setServerError("Invite failed. Please try again.");
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} aria-describedby="invite-help">
<p id="invite-help">Invite a teammate by email.</p>
{serverError ? <div role="alert">{serverError}</div> : null}
<label>
Email
<input type="email" {...form.register("email")} autoComplete="email" />
</label>
{form.formState.errors.email ? (
<div role="alert">{form.formState.errors.email.message}</div>
) : null}
<label>
Role
<select {...form.register("role")}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</label>
<button type="submit" disabled={invite.isPending}>
{invite.isPending ? "Inviting…" : "Send invite"}
</button>
{invite.isSuccess ? <p>Invite sent.</p> : null}
</form>
);
}
This is the pattern you want:
- Validate at the boundary (Zod schema)
- Display client field errors and server errors differently
- Always have an “unknown error” fallback
Compose the route-level page (the slice entry point)
// src/features/teamInvites/ui/TeamMembersPage.tsx
import * as React from "react";
import { MembersList } from "./MembersList";
import { InviteMemberForm } from "./InviteMemberForm";
export function TeamMembersPage() {
return (
<main>
<h1>Team members</h1>
<InviteMemberForm />
<h2>Current members</h2>
<MembersList />
</main>
);
}
Connect it to your router as a normal route component.
Production note: if you use React Router, Next.js, or Remix, prefer route-level boundaries for errors. A single broken widget should not blank the whole app.
Add minimal telemetry (so the slice is operable)
Production readiness includes: “Can we understand what is failing?” You do not need a full observability platform in this tutorial, but you should design for it.
A lightweight pattern:
- Log a structured event when a mutation fails
- Capture status codes
- Include a correlation id if your backend supports it
// src/shared/telemetry/telemetry.ts
export function track(event: string, props?: Record<string, unknown>) {
// Wire this to your real tool (Sentry, Datadog, OpenTelemetry exporter, etc.)
// Keep this interface stable.
console.info("telemetry", event, props ?? {});
}
Use it in the form catch block:
import { track } from "../../shared/telemetry/telemetry";
// ... inside catch
track("team_invite_failed", {
status: e instanceof HttpError ? e.status : "unknown",
});
This is not about console logging. It is about keeping a stable seam so you can plug in real tooling without rewriting slice code.
Tests that protect the slice (without testing implementation details)
Aim for two layers:
- Component behavior tests (Testing Library)
- One happy-path E2E test (Playwright)
Component test: form shows server error
// src/features/teamInvites/__tests__/InviteMemberForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InviteMemberForm } from "../ui/InviteMemberForm";
test("shows a friendly message when server returns conflict", async () => {
// In real code, mock fetch with MSW.
// Here we assume your test setup stubs /api/team/invite with 409.
render(<InviteMemberForm />);
await userEvent.type(screen.getByLabelText(/email/i), "a@b.com");
await userEvent.selectOptions(screen.getByLabelText(/role/i), "member");
await userEvent.click(screen.getByRole("button", { name: /send invite/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(
/already has a pending invite/i
);
});
E2E test: invite appears in list
Your E2E test should prove the vertical slice works end-to-end (UI + API + persistence). If your environments are complex, run E2E against a seeded test backend.
Keep it minimal: one journey, one assertion that matters.
“Definition of done” for a production-ready feature slice
This is the checklist that stops “done” from meaning “merged.”
| Area | What “done” looks like | Why it matters |
|---|---|---|
| Contracts | Request and response types are explicit (and validated if possible) | Prevents silent backend drift |
| States | Loading, empty, and error states are rendered intentionally | Avoids broken UX under real conditions |
| Server state | Fetching and mutations use a consistent pattern (cache + invalidation) | Makes behavior predictable across the app |
| Forms | Client validation + server error mapping | Reduces support load and user frustration |
| Accessibility | Labels, alerts, focus behavior are intentional | Prevents “usable only with a mouse” regressions |
| Tests | At least one behavior test + one E2E journey | Protects the slice during refactors |
| Telemetry seam | Failures can be tracked with structured events | Makes production issues diagnosable |
| Rollout safety | Feature can be disabled (flag) if the business needs it | Makes shipping reversible |
That last row is important. Many teams treat feature flags as “extra.” In practice, flags are a risk control for modern delivery.
Common pitfalls (and how to avoid them)
Pitfall: shared folder becomes a dumping ground
If you move slice-specific helpers into shared too early, you recreate coupling through “utilities.” Keep shared code boring and generic.
A quick rule: if it mentions “member”, “invoice”, “checkout”, or other business nouns, it probably belongs in a feature slice.
Pitfall: mixing three kinds of state
If you store server state, form state, and UI state in one place, changes become risky.
- Server state: TanStack Query (cache, retries, invalidation)
- Form state: React Hook Form
- UI state: local component state (dropdown open, modal open)
Pitfall: testing internals
Avoid tests that assert internal hook calls or state variables. Prefer testing:
- What the user sees
- What the user can do
- What happens when the network fails
Frequently Asked Questions
What is a “feature slice” in React? A feature slice is a self-contained module that owns one user-facing capability end-to-end: route entry, UI, data access, types/contracts, and tests.
Do I need TanStack Query for a production-ready React app? Not strictly, but you need a consistent server-state approach. TanStack Query is a strong default because it standardizes caching, loading, retries, and invalidation.
How big should a feature slice be? Small enough that one team can own it and ship it safely. If the slice contains multiple unrelated journeys, it is probably too big.
Should I build this as a Next.js feature instead of a SPA route? If you need SEO, server rendering, or route-level security boundaries, Next.js can be a better default. If the app is mostly authenticated and highly interactive, an SPA can still be a strong fit. Wolf-Tech’s Next.js vs React decision guide covers the trade-offs.
What is the minimum testing for a slice that is “production-ready”? At minimum: one component behavior test that covers an important failure mode, plus one end-to-end happy-path test for the journey.
Want help standardizing production-ready slices across your React codebase?
If your React app is growing and feature work is slowing down due to inconsistent patterns, fragile state handling, or hard-to-debug production issues, Wolf-Tech can help you define slice boundaries, introduce safe defaults (testing, delivery, observability), and modernize incrementally.
Explore Wolf-Tech at wolf-tech.io and see how we support full-stack delivery, code quality consulting, and legacy modernization without big-bang rewrites.

