Stack · React 19

React 19, used as a discipline rather than a chase

Actions replace the form boilerplate, useOptimistic replaces the optimistic-state plugin you stopped maintaining, Server Components stop being an experiment. We ship React 19 to production today and we migrate React 18 codebases without breaking the team.

Why this stack

01

Actions, not form boilerplate

A form is now a function, not a custom hook. `useState`, `onSubmit`, `setError`, `setSubmitting`, the three pieces of state you wrote on every form, collapse into a single Action. The compiler handles the pending state, the error state, the success state. The form's job goes back to being its name.

02

useOptimistic, not a state-management library for one feature

An optimistic update used to mean a Redux slice, a Zustand store, or a custom reducer. Now it is a hook the size of one line. The UI updates immediately, the server responds, the hook reconciles. No vendored optimistic-state library, no race conditions.

03

The use() hook simplifies async data

Reading a promise inside a component used to mean a Suspense boundary plus a fetcher plus a typed wrapper. The `use()` hook accepts the promise directly and Suspense handles the rest. The data layer shrinks; the boundary becomes the part you actually design.

04

Server Components, stable, not experimental

The Server Component runs on the server, the Client Component runs in the browser, and the seam between them is a clear file-level annotation. The data fetch happens where the data lives. The bundle stays small because most components never reach the browser.

05

Document metadata, ref-as-prop, asset loading: small wins that add up

Native title and meta tags emit from anywhere in the tree, `forwardRef` ceremony is gone, the asset loading API preloads stylesheets and scripts as the page renders. Each one is small; together they shrink the codebase by hundreds of lines.

What we build with it

Production React 19 architecture

Server Components by default, Client Components where interaction lives, file-level "use client" boundaries reviewed for bundle impact.

Forms with React 19 Actions

Every form a typed Action with progressive enhancement, error states surfaced via useActionState, optimistic feedback via useOptimistic.

Optimistic UI patterns

useOptimistic wired to Server Actions for instant feedback, rollback on server error, stale-data handling on success.

Suspense streaming

Long data fetches stream to the browser as they resolve, the page first paint stays under one second, and the slow part lights up when it is ready.

Asset loading and preinit

`preload`, `preinit`, and the new asset loading API replace ad-hoc `<link>` manipulation in the head.

Document metadata native

Per-route title, meta and link tags emitted from the component tree, no head manager library needed.

ref as prop (forwardRef removal)

New components accept `ref` as a regular prop, old components migrated incrementally as they are touched.

TypeScript 5+ strict patterns

Action types, Server Action types, optimistic update types, all generated from the same Zod schemas the server uses.

Migration from React 18

Codebase upgraded in place, behaviour preserved, the new APIs adopted incrementally so the team learns by touching real code.

React Compiler evaluation

We turn it on where it pays off (forms, lists, heavy memo trees) and leave it off where it adds friction.

Concurrent rendering tuning

`useTransition` and `useDeferredValue` placed where they actually matter (typeahead search, large list filters), not sprinkled defensively.

Error boundaries with the new APIs

The new error reporting API, recoverable boundaries, dev-only stack traces wired into the existing logging pipeline.

A form with React 19 Actions, useOptimistic and a Server Action

A comment composer that updates instantly, syncs to the database via a Server Action, surfaces errors inline, and rolls back the optimistic state on failure. No form library, no state-management library, no fetch wrapper.

Most "React 19 features" walkthroughs show you the API in isolation. Production React 19 looks like the form below: an Action handles the submit, useOptimistic updates the UI before the server responds, the Server Action writes to the database, and the error state surfaces inline if anything goes wrong. No form library, no optimistic-state library, no fetch wrapper.

1. The form component

The form receives an action prop. Inside, useActionState carries the server response, useOptimistic holds the in-flight comment, useFormStatus reports whether the form is submitting. All native, all stable.

// app/[lang]/posts/[id]/CommentComposer.tsx
'use client'

import { useActionState, useOptimistic } from 'react'
import { useFormStatus } from 'react-dom'
import { addComment, type ActionState } from './actions'

interface Comment {
  id: string
  body: string
  authorName: string
  createdAt: string
}

const INITIAL_STATE: ActionState = { error: null, comment: null }

export function CommentComposer({
  postId,
  initialComments,
}: {
  postId: string
  initialComments: Comment[]
}) {
  const [state, formAction] = useActionState(addComment, INITIAL_STATE)
  const [optimisticComments, addOptimistic] = useOptimistic(
    initialComments,
    (current, draft: Comment) => [draft, ...current],
  )

  async function clientAction(formData: FormData) {
    const body = formData.get('body')?.toString() ?? ''
    addOptimistic({
      id: 'pending',
      body,
      authorName: 'You',
      createdAt: new Date().toISOString(),
    })
    await formAction(formData)
  }

  return (
    <section>
      <form action={clientAction}>
        <input type="hidden" name="postId" value={postId} />
        <textarea name="body" required minLength={2} />
        <SubmitButton />
        {state.error ? (
          <p role="alert" className="ds-text-error">
            {state.error}
          </p>
        ) : null}
      </form>

      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id}>
            <strong>{c.authorName}</strong>
            <p>{c.body}</p>
          </li>
        ))}
      </ul>
    </section>
  )
}

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Posting…' : 'Post comment'}
    </button>
  )
}

2. The Server Action

The action runs only on the server. The Zod schema validates the input, the Supabase client writes the row, the response shape matches the ActionState the form expects. Revalidation tells Next.js to refetch the comment list on the next navigation.

// app/[lang]/posts/[id]/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { createServerClient } from '@/lib/supabase/server'

const Input = z.object({
  postId: z.string().uuid(),
  body: z.string().min(2).max(2000),
})

export interface ActionState {
  error: string | null
  comment: {
    id: string
    body: string
    authorName: string
    createdAt: string
  } | null
}

export async function addComment(
  _previousState: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const parsed = Input.safeParse({
    postId: formData.get('postId'),
    body: formData.get('body'),
  })
  if (!parsed.success) {
    return { error: 'Please write at least two characters.', comment: null }
  }

  const supabase = await createServerClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Please sign in to comment.', comment: null }

  const { data, error } = await supabase
    .from('comments')
    .insert({
      post_id: parsed.data.postId,
      author_id: user.id,
      body: parsed.data.body,
    })
    .select('id, body, created_at, profiles(display_name)')
    .single()

  if (error) return { error: 'Could not post the comment.', comment: null }

  revalidatePath(`/posts/${parsed.data.postId}`)

  return {
    error: null,
    comment: {
      id: data.id,
      body: data.body,
      authorName: data.profiles?.display_name ?? 'Anonymous',
      createdAt: data.created_at,
    },
  }
}

3. The Server Component that hosts it

The Server Component reads the existing comments (row-level security scopes the query to comments the user is allowed to see) and renders the client CommentComposer with the data already in place.

// app/[lang]/posts/[id]/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import { CommentComposer } from './CommentComposer'

interface Props {
  params: Promise<{ id: string }>
}

export default async function PostPage({ params }: Props) {
  const { id } = await params
  const supabase = await createServerClient()

  const { data: comments } = await supabase
    .from('comments')
    .select('id, body, created_at, profiles(display_name)')
    .eq('post_id', id)
    .order('created_at', { ascending: false })

  const initial = (comments ?? []).map((c) => ({
    id: c.id,
    body: c.body,
    authorName: c.profiles?.display_name ?? 'Anonymous',
    createdAt: c.created_at,
  }))

  return <CommentComposer postId={id} initialComments={initial} />
}

4. What this collapses

A pre-React-19 version of this feature needed: a form library for state and validation, a fetcher for the POST request, an optimistic state library (or a custom reducer), a Suspense boundary at the page level, a manual revalidation step, and a separate error display. All five concerns now sit in three files, all native, all type-checked, all reviewed by the same person without coordination across libraries.

The "React 19 stack" is not a list of new APIs to chase. It is a stack of dependencies that disappear because the framework finally absorbed them.

Frequently asked questions

React 19 is stable enough for production?

Yes. We have shipped React 19 RC and 19 stable to production across multiple apps since release. Known caveats (third-party libraries with stale peer dependencies, server-component edge cases) are documented in our internal playbook and we work around them in code.

Should we upgrade from React 18 now?

Depends on the codebase. Most React 18 apps benefit from upgrading because the new APIs replace a stack of dependencies; the migration takes one to three days for a medium codebase. We will tell you straight if the upgrade is not worth the work yet.

Is the React Compiler ready?

Ready enough for many forms, lists and heavy memo trees, not ready for every codebase. We benchmark with and without it for your application before shipping; the wins are concrete in some places and zero in others.

When do we use Server Components vs Client Components?

Server by default. Client only when the component reads browser-only APIs (useState, useEffect, window, intersection observer, etc.) or interacts with input directly. We review every `'use client'` boundary for bundle impact.

useOptimistic vs Zustand or Redux for optimistic updates?

useOptimistic for the simple case (a single form, a single list mutation), Zustand or Redux only when state genuinely lives across many components and needs persistence beyond a single user interaction. Most projects we touch lose the state library after the React 19 upgrade.

TypeScript-strict with React 19?

Yes, every type narrows down from the Action input, through the Server Action signature, to the database row. We use Zod at the boundary so the runtime check and the compile-time type are the same shape.

How do you handle migration from libraries that lag React 19?

Either we pin the library and live with React 18 peer-dep warnings (mostly harmless), or we swap the library for an actively maintained alternative. The migration plan calls out every library that lags so the cost is visible up front.

Tell us what you are building on React 19

A scoping call, a concrete number in the first reply, no agency theater. New builds and React 18 migrations both welcome.