sales@mtechzilla.com+1 302 208 5468

How to Create a React App in 2026: A Complete Guide

27 Jul 2026

How to Create a React App in 2026: A Complete Guide

Summary:

A hands-on guide to creating a React app in 2026 that teaches React fundamentals through a real invoicing MVP called InvoiceRadar rather than in the abstract. It covers what React is and why it still matters, how to set up a project with Vite and TypeScript, and how to choose a stack—then puts core concepts to work: Server Components, Suspense, TanStack Query, useOptimistic, Context, useReducer, useTransition, and lazy loading. Every fundamental is shown as working code solving an actual problem in the app.

React is a declarative, component-based JavaScript library for building user interfaces. It exists because keeping the DOM manually in sync with application state gets painful fast: as an interface grows, hand-written update logic turns into a web of edge cases nobody can reason about. React flips the model. You describe what the UI should look like for a given state, and React works out the minimal set of DOM changes to get there.

That model still holds up in 2026. Components stay reusable across projects, the ecosystem around React is stable rather than in constant churn, and the React 19 compiler now handles a lot of the memoization developers used to write by hand. You get the performance without babysitting useMemo.

Instead of explaining those ideas in the abstract, this guide builds a real application and shows each concept working inside it.

The app is InvoiceRadar, a small invoicing and payment-tracking tool. It comprises of four invoice statuses, one board, one approval gate, one feature flag, and not much else. By the end you will have wired up client-side data fetching with suspense, optimistic updates, real-time polling, an approval workflow, an audit log, and a feature flag. Every one of them is there to solve a problem the app actually has, not to show off.

If you would rather have an experienced team build something like this for you, our React development services can help.

Setting Up Your React Project

The fastest reliable way to start a React app in 2026 is Vite with the React + TypeScript template. Run these four steps in order.

First, scaffold the project:

npm create vite@latest invoice-radar -- --template react-ts

Move into the new folder and install the base dependencies:

cd invoice-radar && npm install

Add the runtime libraries this guide uses:

npm install @tanstack/react-query @dnd-kit/core @dnd-kit/sortable

Start the dev server:

npm run dev

That gets you a running app with hot reload, TypeScript configured in strict mode, and the three libraries InvoiceRadar depends on: TanStack Query for the data layer, and the two @dnd-kit packages for drag-and-drop. Vite's dev server starts almost instantly and reloads changes without a full page refresh, which is most of why it has become the default starting point for new client-rendered React apps. If npm run dev prints a local URL and the starter page loads, you are set.

Before writing features, lay out a folder structure. Keep it flat while the app is small:

invoice-radar/
└── src/
    ├── components/   # UI: InvoiceCard, StatusColumn, Gate, etc.
    ├── hooks/        # useRole, useFeatureFlag, and data hooks
    ├── lib/          # validateStatusChange, query client, helpers
    └── types/        # the shared type definitions

The rule behind it: group by what a file is, not by feature, until the app is large enough that a feature owns enough files to justify its own folder. For an MVP, that day has not arrived yet.

The InvoiceRadar Problem and Domain Model

InvoiceRadar tracks money owed to a team and the state each invoice is in. The core idea is a pipeline: a fixed set of statuses an invoice moves through in a known order, so anyone can see at a glance where every bill stands. Four entities carry the whole app:

  • Client; whoever owes money. Just an id and a name in the MVP.

  • Invoice; a single bill tied to a client, with an amount, a dueDate, and a status that is one of draft, sent, overdue, or paid.

  • ApprovalRequest; a pending decision on an invoice, tracking who requested it and whether it is pending, approved, or rejected.

  • AuditEntry; a record of who did what and when, so every approval or rejection leaves a trail.

Here are the types, which the rest of the guide relies on:

// src/types/index.ts                                              type Client = {
  id: string;
  name: string;
};

type Invoice = {
  id: string;
  clientId: string;
  amount: number;
  dueDate: string; // ISO date
  status: 'draft' | 'sent' | 'overdue' | 'paid';
};

type ApprovalRequest = {
  id: string;
  invoiceId: string;
  requestedBy: string;
  status: 'pending' | 'approved' | 'rejected';
};

type AuditEntry = {
  id: string;
  actorId: string;
  action: string;
  targetId: string;
  timestamp: string; // ISO datetime
};

One thing worth calling out now: the overdue status is set by the system based on dueDate, never chosen by a user. An invoice goes overdue on its own once the deadline passes.

Choosing Your React Stack

The right stack depends on whether you need server-side rendering. Industry surveys have consistently shown React among the most widely used frontend libraries year over year, so whichever option you pick, you are choosing within a large and stable ecosystem rather than betting on something niche. Here is how the three common options compare:

The InvoiceRadar Problem and Domain Model

InvoiceRadar uses Vite + React 19 + TypeScript. It is an internal, client-rendered tool with no SEO or server-rendering requirement, so the extra machinery of a server framework would cost more than it returns. Vite gives the fastest dev loop and the simplest mental model, which is what an MVP wants.

Pick a server framework when the page needs to render before JavaScript loads; otherwise Vite is usually the lighter choice.

Stack Comparison

Structuring the Project

The structure follows the two features. The data layer, the feature flag, and the shared helpers each get a clear home so nothing bleeds across boundaries.

TanStack Query owns all server communication. A single QueryClient gets created in lib/ and provided at the app root, and any component that reads server data goes through a query hook instead of calling fetch itself. Caching, polling, and refetching all live in one place that way.

The feature flag sits in a FlagContext in lib/, read through a useFeatureFlag() hook. Context means any component can check a flag without prop-drilling, and flipping a flag only re-renders the components that actually read it.

The validateStatusChange() helper lives in lib/ too. Both features call it, and keeping it shared means the pipeline's drop handler and the approval flow enforce the same rules rather than drifting apart. Role information comes through a RoleContext, read with useRole(), so permission checks stay consistent.

So you end up with three places that own state: server state in TanStack Query, permission state in RoleContext, flag state in FlagContext. Components stay thin and mostly just render.

Keep every piece of shared state behind a single hook so a component never has two ways to ask the same question.

Component Architecture

Building Feature 1: Real-Time Invoice Pipeline

What it does

  • Live status; see an invoice's status update as teammates make changes, without refreshing.

  • Drag to move; move an invoice by dragging its card between columns, not by opening a form.

  • Automatic overdue flagging; invoices that pass their due date get flagged as overdue on their own.

How it's built

The board splits into two client components: a thin outer one that owns the Suspense boundary, and an inner one that reads the data. On a plain Vite SPA there is no server render pass, so "initial data" comes from the first query rather than a server component. TanStack Query's useSuspenseQuery suspends the inner component until that first fetch resolves, which is what lets the skeleton stream in:

// src/components/InvoiceBoard.tsx — owns the Suspense boundary.
import { Suspense } from 'react';
import { InvoicePipeline } from './InvoicePipeline';

export function InvoiceBoard() {
  // While InvoicePipeline's initial query is in flight, useSuspenseQuery
  // suspends and this fallback shows. No server runtime needed — the
  // suspense here is driven entirely by the client-side data fetch.
  return (
    <Suspense fallback={<BoardSkeleton />}>
      <InvoicePipeline />
    </Suspense>
  );
}

The inner board fetches its own initial data, then polls every ten seconds with TanStack Query, which is also how it picks up invoices the server has just moved into Overdue:

// src/components/InvoicePipeline.tsx — a Client Component.
import { useSuspenseQuery } from '@tanstack/react-query';
import { useOptimistic, useTransition } from 'react';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { getPipelineData, updateInvoiceStatus } from '../lib/api';
import { validateStatusChange } from '../lib/validateStatusChange';

export function InvoicePipeline() {
  // useSuspenseQuery fetches the initial board on the client and suspends
  // until it resolves (the parent <Suspense> shows the skeleton meanwhile).
  // Poll every 10s: because the server recomputes which Sent invoices are
  // now past due on each request, this same poll surfaces server-driven
  // Overdue transitions — no separate mechanism needed.
  const { data: invoices } = useSuspenseQuery({
    queryKey: ['pipeline'],
    queryFn: getPipelineData,
    refetchInterval: 10_000,
  });

  // Optimistic layer: the UI applies the drop instantly. Reversion is tied
  // to the transition below — when the action settles, React discards the
  // optimistic value and the component falls back to real query state.
  const [optimisticInvoices, applyOptimistic] = useOptimistic(
    invoices,
    (current, moved: Invoice) =>
      current.map((inv) => (inv.id === moved.id ? moved : inv)),
  );

  // useTransition gives us the pending flag and, crucially, the boundary
  // that useOptimistic reverts against.
  const [, startTransition] = useTransition();

  // --- @dnd-kit wiring -------------------------------------------------
  // A pointer sensor so a small drag distance is required before a drag
  // starts; this prevents accidental drags when the user just clicks.
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
  );

  // onDragEnd fires when the user releases a card. `active` is the card
  // being dragged; `over` is the column it was dropped on (or null if
  // dropped outside any column).
  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (!over) return; // dropped outside a column — do nothing

    const invoice = invoices.find((i) => i.id === active.id)!;
    const newStatus = over.id as Invoice['status']; // column id === status

    // Run the shared rule check. Treat this as a black box: it rejects
    // moves to Paid without approval, and any manual move into Overdue.
    const result = validateStatusChange(invoice, newStatus);
    if (!result.ok) {
      showToast(result.reason);
      return; // never even apply the optimistic update
    }

    // Apply the optimistic update AND the server call inside one transition.
    // React owns the revert: if the action throws, the optimistic value is
    // discarded when the transition settles and the card snaps back — no
    // manual rollback code, and the setter runs in its documented context.
    startTransition(async () => {
      applyOptimistic({ ...invoice, status: newStatus });
      try {
        await updateInvoiceStatus(invoice.id, newStatus);
      } catch {
        showToast('Could not update invoice. Reverted.');
      }
    });
  }
  // --- end @dnd-kit wiring ---------------------------------------------

  return (
    <BoardErrorBoundary>
      <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
        {(['draft', 'sent', 'overdue', 'paid'] as const).map((status) => (
          <StatusColumn
            key={status}
            status={status}
            invoices={optimisticInvoices.filter((i) => i.status === status)}
          />
        ))}
      </DndContext>
    </BoardErrorBoundary>
  );
}

A single error boundary wraps the whole board, so a render failure anywhere shows one fallback instead of taking down the page.

Why it's built this way

  • Client-side data fetching; on a plain Vite SPA there is no server render, so the initial board loads through a query on the client rather than a server component. useSuspenseQuery makes that first fetch integrate with Suspense, so you get streamed-in loading without any RSC runtime.

  • Suspense; a boundary that shows a fallback while a child is still suspended. Here useSuspenseQuery suspends the board until its first fetch resolves, so the skeleton shows immediately and the user never stares at a blank screen.

  • TanStack Query polling; a refetchInterval refetches on a timer and updates the cache. Because the server recomputes overdue invoices per request, one polling loop covers both teammate changes and system-driven status changes.

  • useOptimistic + useTransition; useOptimistic applies a temporary state update whose reversion is tied to a transition. Calling the setter inside startTransition is the documented pattern: the drag feels instant, and if the server call fails the optimistic value is discarded when the transition settles — no manual rollback code.

  • error boundaries; a component that catches render errors in its subtree. One boundary around the board is enough for an MVP and keeps a single bug from blanking the app.

If you would rather have this built and running fast, hire React developers who work with this stack daily, and feel free to contact us to talk through scope.

Try this: comment out the useOptimistic call and re-run on a throttled network. You will feel the card hang in place until the server responds, which is exactly the lag optimistic state hides.

Building Feature 2: Approvals, Audit Trail & Feature Flags

What it does

  • Second approval; invoices above a set amount need a second person to approve them before they can be marked paid.

  • Automatic logging; every approval or rejection is written to an audit trail without anyone remembering to record it.

  • Selective rollout; turn on an AI reminder feature for overdue invoices for some customers without redeploying the app.

How it's built

Roles come from a context and a hook. A <Gate> wrapper renders its children only when the current role matches, which keeps permission checks out of the JSX:

// src/hooks/RoleContext.tsx — provides the current user's role.
const RoleContext = createContext<Role | null>(null);
export const useRole = () => useContext(RoleContext)!;

// src/components/Gate.tsx — renders children only for a matching role.
export function Gate({ role, children }: { role: Role; children: ReactNode }) {
  const current = useRole();
  return current === role ? <>{children}</> : null; // one place, one check
}

Approve and reject handlers run inside useTransition, so the controls disable and show a spinner during the server call and cannot be double-clicked:

// src/components/ApprovalControls.tsx
function ApprovalControls({ invoice, dispatch }: Props) {
  const [isPending, startTransition] = useTransition();

  const decide = (action: 'APPROVE_INVOICE' | 'REJECT_INVOICE') => {
    // startTransition keeps the UI responsive and marks isPending true
    // for the duration, which disables both buttons — no double submits.
    startTransition(async () => {
      await submitDecision(invoice.id, action);
      dispatch({ type: action, invoiceId: invoice.id, actorId: currentUser.id });
    });
  };

  return (
    <Gate role="approver">
      <button disabled={isPending} onClick={() => decide('APPROVE_INVOICE')}>
        Approve
      </button>
      <button disabled={isPending} onClick={() => decide('REJECT_INVOICE')}>
        Reject
      </button>
    </Gate>
  );
}

The audit log is managed by a reducer. This is the only place an audit entry is ever created, which is what makes the trail trustworthy:

// src/lib/auditReducer.ts
type AuditAction =
  | { type: 'APPROVE_INVOICE'; invoiceId: string; actorId: string }
  | { type: 'REJECT_INVOICE'; invoiceId: string; actorId: string }
  | { type: 'LOG_ENTRY'; entry: AuditEntry };

function auditReducer(state: AuditEntry[], action: AuditAction): AuditEntry[] {
  switch (action.type) {
    // APPROVE_INVOICE and REJECT_INVOICE never write to the log directly.
    // Instead each one builds a LOG_ENTRY describing what happened and
    // recurses through the reducer, so there is exactly ONE code path that
    // appends to the audit trail. This is deliberate: if entries could be
    // created in more than one place, the log could drift out of sync with
    // reality and the audit trail would stop being reliable.
    case 'APPROVE_INVOICE':
      return auditReducer(state, {
        type: 'LOG_ENTRY',
        entry: makeEntry(action.actorId, 'approved', action.invoiceId),
      });

    case 'REJECT_INVOICE':
      return auditReducer(state, {
        type: 'LOG_ENTRY',
        entry: makeEntry(action.actorId, 'rejected', action.invoiceId),
      });

    // LOG_ENTRY is the single append point. Every audit entry in the system
    // passes through exactly this line, with an actorId and a timestamp.
    case 'LOG_ENTRY':
      return [...state, action.entry];

    default:
      return state;
  }
}

The AI reminder widget is gated behind a feature flag and lazy-loaded, so it only ships to workspaces that should see it:

// src/components/ReminderSlot.tsx
// Read the flag from context; lazy-load the widget so its code only
// downloads when actually rendered.
const AIReminderWidget = lazy(() => import('./AIReminderWidget'));

function ReminderSlot({ plan }: { plan: 'free' | 'pro' }) {
  const enabled = useFeatureFlag('ai-reminders');

  // Render only when the flag is on AND the workspace is on the pro plan.
  if (enabled && plan === 'pro') {
    return (
      <Suspense fallback={<WidgetSkeleton />}>
        <AIReminderWidget />
      </Suspense>
    );
  }
  return <UpgradeTeaser />; // shown otherwise
}

Why it's built this way

  • RoleContext + useRole; context holds the current role and a hook reads it. Permission state lives in one place, so no component invents its own way of checking who the user is.

  • Gate wrapper; a component that renders children conditionally on role. It concentrates every role check into one composable element instead of scattering if statements through the tree.

  • useTransition; marks an async update as non-blocking and exposes an isPending flag. That flag disables the controls during the server call, which stops double-submits for free.

  • useReducer for the audit log; a reducer centralizes state transitions. Making it the only place entries are created means the trail cannot be written from a dozen scattered call sites and drift out of sync.

  • useFeatureFlag + lazy(); a hook reads the flag and lazy() defers the widget's code. Together they let you turn a feature on for some customers without shipping its bundle to everyone or redeploying.

Try this: replace <Gate role="approver"> with an inline role === 'approver' && conditional in each spot. Watch the same check spread across files, and imagine changing the rule later.

Key Takeaways

Each problem InvoiceRadar set out to solve maps to a specific React tool:

Each problem InvoiceRadar set out to solve maps to a specific React tool:

The through-line is simple: give each responsibility to the primitive that fits it, then stay out of its way. Optimistic state covers perceived speed. Polling covers freshness. A reducer keeps the log honest. A flag handles rollout. Individually none of these is doing anything fancy. What matters is picking the right one for each job and keeping the whole thing small enough that you can still read it six months later.

Decision Tree for Choosing a Stack

Conclusion

InvoiceRadar is deliberately small, and that is the whole point. A handful of statuses, one board, one approval gate, one flag, each backed by the React primitive that fits it. Build it in this order and nothing is there without a reason, which is what keeps an app from turning into a mess as it grows. If you want help designing or building something like this, contact us to talk it through, whether that is a quick prototype or full product engineering for startups.

Appendix: AI Prompts Used to Build These Features

If you want to practice writing your own feature prompts, use the two below. Pasting either into an AI coding assistant produces code equivalent to what the sections above show.

Feature 1: Real-Time Invoice Pipeline

Build the InvoicePipeline feature for a React 19 + TypeScript app called 
InvoiceRadar. This is an MVP — keep the implementation minimal.

Goal: a kanban-style board with four columns — Draft, Sent, Overdue, Paid. 
Users drag InvoiceCard components between StatusColumn components to 
update an invoice's status, EXCEPT Overdue: that column is system-managed. 
An invoice moves there automatically once its due date passes while it's 
Sent — the server recalculates this on each request. Users cannot manually 
drag a card into Overdue; if attempted, treat it like any other invalid 
transition.

Data shape: Client { id, name }, Invoice { id, clientId, amount, dueDate, 
status }. Initial board data is fetched in a Server Component and 
streamed; wrap the board in Suspense with a skeleton fallback.

Requirements, using these exact React APIs:
- The board itself is a Client Component. Poll for live updates every 10s 
  with TanStack Query — this is also how the board picks up invoices the 
  server has just moved into Overdue.
- Use @dnd-kit for drag-and-drop (DndContext, useDraggable on InvoiceCard, 
  useDroppable on StatusColumn). Comment the DnD wiring heavily — every DnD 
  hook, the sensors setup, and the onDragEnd handler must have inline 
  comments a junior developer can follow without reading @dnd-kit docs.
- On drop, use useOptimistic so the status change appears instantly, then 
  call the server action. If the pre-written helper 
  validateStatusChange(invoice, newStatus) rejects it — for example, an 
  invoice can't move to Paid without approval, and no one can manually drop 
  a card into Overdue — roll back and show a toast. Treat 
  validateStatusChange as a black box.
- Wrap the whole board in a single error boundary. Do not add per-widget 
  boundaries or any extra components — keep this an MVP.

Acceptance criteria: drag feels instant even on slow network; rejected 
changes (including manual drags into Overdue) roll back visibly; the board 
reflects other team members' changes and server-driven Overdue transitions 
within 10s; TypeScript strict mode passes.

The useOptimistic requirement stated in the prompt is why the drop handler above applies the move before the server responds, and the "single error boundary" line is why the board has exactly one boundary rather than per-column ones.

One adaptation to note: the prompt asks for initial data "fetched in a Server Component and streamed," which assumes a server-capable framework like Next.js App Router. Because InvoiceRadar runs on a plain Vite SPA (no RSC runtime), the code above keeps the same Suspense-driven loading but fetches on the client with useSuspenseQuery instead — if you paste this prompt into an assistant targeting Next.js, you would get the literal Server Component version.

Feature 2: Approvals, Audit Trail & Feature Flags

Build the InvoiceApproval feature for InvoiceRadar (React 19 + 
TypeScript). This is an MVP — keep the implementation minimal.

Goal: invoices above a threshold amount require approval before being 
marked Paid. Every approval action is recorded in an audit trail. A 
Pro-plan-only AIReminderWidget — for sending reminder emails on Overdue 
invoices — is gated behind a single feature flag and lazy-loaded.

Data shape: ApprovalRequest { id, invoiceId, requestedBy, status: 
'pending' | 'approved' | 'rejected' }, AuditEntry { id, actorId, action, 
targetId, timestamp }.

Requirements, using these exact React APIs:
- Create a RoleContext provider and a useRole() custom hook. Build a 
  composable <Gate role="approver"> wrapper component that renders 
  children only for matching roles — the Approve/Reject controls live 
  inside it. Do not scatter role checks through JSX conditionals.
- Wrap the approve/reject handlers in useTransition so the pending state 
  disables both controls and shows a spinner during the server action — no 
  double-submits.
- Manage the audit log with useReducer: actions APPROVE_INVOICE, 
  REJECT_INVOICE, and LOG_ENTRY. Every approve/reject must dispatch a 
  LOG_ENTRY with actorId and timestamp — the reducer is the only place 
  audit entries are created.
- Create a useFeatureFlag(flagName) hook reading from a FlagContext. 
  Lazy-load AIReminderWidget with lazy() + Suspense, and render it only 
  when useFeatureFlag('ai-reminders') AND the workspace plan is 'pro'. 
  Show an upgrade teaser otherwise.
- Lightly comment everything except the reducer, which should be commented 
  thoroughly.
- Keep this scoped to exactly these requirements — no extra roles, flags, 
  or widgets.

Acceptance criteria: users without the approver role never see the 
approve/reject controls; every status change produces exactly one audit 
entry; controls cannot be double-clicked; flipping the flag toggles the 
lazily-loaded widget without a redeploy; TypeScript strict mode passes.

The "reducer is the only place audit entries are created" line in the prompt is why the reducer above routes both decisions through a single LOG_ENTRY case, and the flag-plus-plan condition is why the widget checks both useFeatureFlag('ai-reminders') and the pro plan before rendering.

A
Written byApurva ShahCTO

Leading engineering teams to build scalable, high-impact digital products.