DAILY NEWS

Stay Ahead, Stay Informed – Every Day

Advertisement
React 19 Features — What Actually Changed and What I Use



React 19 shipped a laundry list of features. Twitter threads treated every hook like mandatory. In production on client sites and this portfolio, I adopted a subset — the ones that remove real bugs or UX jank — and ignored the rest until the ecosystem caught up. This is my honest react 19 features guide: what changed, code you can paste, and what I am still waiting on.
What actually changed at a high level
React 19 stabilised the Actions model (forms and mutations with pending state), added useOptimistic for instant UI feedback, introduced the use() hook for reading promises and context, improved hydration error messages, and made ref-as-prop cleaner. The compiler (React Forget) is separate — exciting, not required to upgrade.
Upgrade path: Next.js 15 projects already pin compatible React versions. Read RSC vs client components before mixing Actions with Server Components — boundaries still matter.
React 19 hooks and APIs — quick reference table

API
Purpose
Client / Server
I use in prod?

useOptimistic
Optimistic UI while mutation runs
Client
Yes

use()
Read promise or context
Both (with Suspense)
Yes (with RSC)

useActionState
Form action state
Client
Yes

useFormStatus
Pending from parent form
Client
Yes

ref as prop
No forwardRef boilerplate
Both
Gradual

Document metadata
title, meta in components
Client (limited)
Prefer Next.js metadata

useOptimistic — instant feedback without lying to the user forever
Cart quantity updates, like buttons, todo toggles — users expect instant UI. useOptimistic shows the next state while the server catches up, then reconciles on success or rolls back on error.
“use client”;import { useOptimistic, useTransition } from “react”;import { updateQuantity } from “./actions”;

type Item = { id: string; qty: number };

export function CartLine({ item }: { item: Item }) {const (optimisticQty, setOptimisticQty) = useOptimistic(item.qty);const (isPending, startTransition) = useTransition();

function changeQty(next: number) {startTransition(async () => {setOptimisticQty(next);await updateQuantity(item.id, next);});}

return ( changeQty(optimisticQty + 1)} disabled={isPending}>+{optimisticQty});}
// BEFORE — manual optimistic state with footgunsconst (qty, setQty) = useState(item.qty);const (pending, setPending) = useState(false);

async function bump() {const prev = qty;setQty(qty + 1); // optimisticsetPending(true);try {await updateQuantity(item.id, qty + 1);} catch {setQty(prev); // easy to forget rollback paths} finally {setPending(false);}}

// AFTER — useOptimistic + transition: rollback wired correctly
use() — promises and context without useEffect hacks
// Server Component passes a promise to client childimport { use } from “react”;

type Product = { id: string; name: string };

function ProductList({ productsPromise }: { productsPromise: Promise }) {const products = use(productsPromise); // suspends until resolvedreturn ({products.map((p) => ({p.name}))});}

// Parent (Server Component) creates the promise onceexport default function Page() {const productsPromise = getProducts(); // do not await herereturn (Loading products…}>);}
In production I use use() with Server Components streaming data — same mental model as async server fetch, less client useEffect spaghetti.
Actions and forms — less boilerplate than manual fetch
“use client”;import { useActionState } from “react”;import { subscribe } from “./actions”;

export function NewsletterForm() {const (state, formAction, pending) = useActionState(subscribe, { ok: false, message: “” });

return ();}
Pair with Next.js Server Actions for mutations without a separate API route file — still validate on the server, still treat client state as untrusted.
What I immediately adopted
useOptimistic on any user-facing mutation where latency is felt on Indian mobile networks. useActionState / useFormStatus on marketing forms — fewer lines than custom pending flags. use() with Suspense boundaries on catalog sections fed from server promises. Better hydration errors — saved me an hour debugging a client-only chart imported into a Server Component (fixed by splitting the leaf).
// ref as prop — dropped forwardRef on new componentstype ButtonProps = React.ComponentProps & { ref?: React.Ref };

export function Button({ ref, …props }: ButtonProps) {return ;}
What I’m waiting on
React Compiler (Forget) — I will enable it per-route after stable Next.js integration docs, not on day one of React 19. Document metadata in client trees — I still use Next.js generateMetadata for SEO. Full ecosystem typings — some third-party libs lagged React 19 types for weeks; I pinned versions until they caught up.
I am also not rewriting every forwardRef component overnight — new code uses ref-as-prop; old code migrates on touch.
Waiting is a strategy, not laziness. The compiler will change how much manual memo we write — see my useCallback vs useMemo guide for why I am not adding more memo hooks while the ecosystem catches up.
My production setup
In production: React 19 + Next.js 15, Server Components by default, React 19 Actions on forms that need pending UX, optimistic updates on commerce interactions. Performance work still lives in caching and bundle size — see Next.js performance case study.
When experimenting, I use the workflow in Cursor + Claude for React — AI suggests React 19 APIs quickly, but I verify against official release notes before merge.
The single takeaway
React 19 is not a rewrite mandate. Adopt optimistic UI, Actions, and use() where they solve problems you already have. Wait on compiler and metadata experiments until your stack documents them.
Related: Next.js vs React learning path. Contact.
If this helped you
I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:

Related reading
More guides on safdarali.in — same author, production-focused.



Source link

React useDebounce Hook: Debounce State & Callbacks (2026)



You have a search box. The user types react hooks, and your component fires an API request on every single keystroke — eleven requests for one query, ten of them already stale by the time they resolve. The fix everyone reaches for is debouncing: wait until the typing stops, then fire once. The fix everyone gets wrong is writing that debounce by hand with setTimeout inside a component, where stale closures, missing cleanup, and re-render churn quietly break it.

useDebounce is the hook that gets it right. This post covers the two shapes you actually need — debouncing a value and debouncing a callback — when to use each, and how to cancel or flush pending calls. Everything here is the real @reactuses/core API, SSR-safe and typed.

Why Not Just Use setTimeout?

Debouncing itself is simple: delay a function until a quiet period has passed, restarting the timer on every new call. (If you want the full conceptual breakdown — and how it differs from throttling — see Debounce vs Throttle in React.) The hard part is doing it inside a React component. Here is the naive version, and it has three bugs:

function Search() {
const (query, setQuery) = useState(”);
const timer = useRefReturnTypetypeof setTimeout>>();

function handleChange(e: React.ChangeEventHTMLInputElement>) {
const value = e.target.value;
setQuery(value);
clearTimeout(timer.current);
timer.current = setTimeout(() => {
fetchResults(value); // 🐛 see below
}, 300);
}

return input value={query} onChange={handleChange} />;
}

Enter fullscreen mode

Exit fullscreen mode

It leaks on unmount. If the component unmounts while a timer is pending, the callback still fires 300 ms later — often setting state on a gone component, or hitting an API for a screen the user already left.

It captures stale values. The moment you debounce anything other than the raw event value — a second piece of state, a prop, a derived value — the closure freezes whatever those were when the timer was set, not when it fires.

It spreads. Every place that needs debouncing re-implements the useRef + clearTimeout dance, and each copy is a chance to forget the cleanup.

A hook fixes all three in one place. ReactUse ships two, built on the battle-tested lodash.debounce internally so the edge cases (leading edge, max wait, trailing edge) are already handled.

useDebounce — Debounce a Value

The most common case: you have a value that changes rapidly and you want a second, lagging copy of it that only updates after things settle. That second copy is what you feed into expensive work.

import { useState, useEffect } from ‘react’;
import { useDebounce } from ‘@reactuses/core’;

function Search() {
const (query, setQuery) = useState(”);
const debouncedQuery = useDebounce(query, 300);

useEffect(() => {
if (!debouncedQuery) return;
fetchResults(debouncedQuery);
}, (debouncedQuery));

return (
input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder=”Search…”
/>
);
}

Enter fullscreen mode

Exit fullscreen mode

The signature is useDebounce(value, wait?, options?) and it returns the debounced value, with the same type as the input:

const debounced = useDebounce(value, 300);

Enter fullscreen mode

Exit fullscreen mode

The input (query) updates on every keystroke, so the controlled stays perfectly responsive — that’s the value you bind to the DOM. The output (debouncedQuery) only catches up 300 ms after the user stops typing, so it’s the value you put in the effect’s dependency array. The API fires once per pause instead of once per keystroke, and your input never feels laggy because the thing you typed into was never the thing being debounced.

This pattern — fast value for the UI, debounced value for the side effect — is the whole point. Keep them as two separate variables and the rest falls into place.

useDebounceFn — Debounce a Callback

Debouncing a value is great when the thing you want to throttle is state. But sometimes you want to debounce an action that takes arguments — an autosave, an analytics event, a resize handler — without routing it through state first. That’s useDebounceFn:

import { useDebounceFn } from ‘@reactuses/core’;

function Editor({ docId }: { docId: string }) {
const { run } = useDebounceFn((content: string) => {
saveDraft(docId, content);
}, 1000);

return (
textarea onChange={(e) => run(e.target.value)} />
);
}

Enter fullscreen mode

Exit fullscreen mode

useDebounceFn(fn, wait?, options?) returns an object with three members:

const { run, cancel, flush } = useDebounceFn(fn, 1000);

Enter fullscreen mode

Exit fullscreen mode

run — the debounced function. Call it as often as you like; fn only actually executes after the calls stop for wait ms. It forwards every argument through, so run(content) calls fn(content).

cancel — drop any pending invocation. Nothing fires.

flush — fire the pending invocation right now, instead of waiting out the timer.

Crucially, run always calls the latest version of your fn. Internally the hook keeps your callback in a ref, so even though the debounced wrapper is created once, it never goes stale — the docId closure problem from the setTimeout version simply doesn’t exist here. And the hook cancels any pending call automatically on unmount, so bug #1 is gone too.

useDebounce is actually built on top of useDebounceFn — it debounces a setState call and hands you the resulting value. Same engine, two ergonomics.

cancel and flush in practice

The cancel/flush pair is what raw setTimeout makes painful and a hook makes trivial. Two real cases:

function CommentBox() {
const { run: autosave, cancel, flush } = useDebounceFn(
(text: string) => saveDraft(text),
2000,
);

return (

textarea onChange={(e) => autosave(e.target.value)} />
{/* User hit “Post” — persist immediately, don’t wait out the 2s */}
button onClick={() => flush()}>Postbutton>
{/* User hit “Discard” — throw away the pending autosave */}
button onClick={() => cancel()}>Discardbutton>
>
);
}

Enter fullscreen mode

Exit fullscreen mode

flush guarantees the in-flight draft is written before the post request goes out; cancel makes sure a discarded draft doesn’t get saved a beat later. Both are one call.

Value or Callback — Which One?

A quick decision rule:

Reach for useDebounce when you’re debouncing a piece of state that something else reads — a search term, a filter, a slider value feeding a chart. You want a lagging value.
Reach for useDebounceFn when you’re debouncing an action with arguments — autosave, logging, firing a network request directly. You want a lagging function, plus cancel/flush control.

If you find yourself creating a piece of state only to debounce it and then immediately fire an effect, useDebounceFn is usually the more direct tool.

Tuning: leading, trailing, and maxWait

The optional third argument is passed straight through to lodash.debounce, so you get its full options object:

useDebounce(value, 300, {
leading: false, // don’t fire on the very first call (default)
trailing: true, // fire after the pause (default)
maxWait: 1000, // …but never wait longer than 1s total
});

Enter fullscreen mode

Exit fullscreen mode

Two knobs worth knowing:

leading: true fires on the first call immediately, then debounces the rest. Good for “respond instantly, then settle” interactions — the first click of a button feels snappy while rapid repeats are absorbed.

maxWait caps the total delay. With a pure trailing debounce, a user who types continuously for ten seconds gets zero updates until they stop. maxWait: 1000 forces an update at least once a second even mid-burst — the difference between a search box that feels alive and one that feels frozen.

SSR Safety

Both hooks are safe to render on the server. They touch no window, document, or browser timer during render — the debounced work only ever runs inside effects, which React never executes on the server. Drop them into a Next.js, Remix, or Astro component and there’s no typeof window guard to write, no hydration warning to chase. (If SSR-safety is a running theme in your codebase, SSR-Safe React Hooks goes deeper.)

The Rate-Limiting Family

useDebounce has three close relatives in ReactUse; pick by what you’re limiting and which shape you need:

The throttle pair mirrors the debounce pair exactly — same (value/fn, wait, options) signature, same return shapes — but enforces a steady cadence instead of waiting for silence. Use throttle for things that should update during a continuous gesture (scroll position, drag coordinates, a live progress readout); use debounce for things that should update only after it ends (search, autosave, validation). The full mental model is in Debounce vs Throttle in React: When to Use Which.

Takeaways

A hand-rolled setTimeout debounce inside a component ships three bugs by default: it leaks on unmount, it captures stale closures, and it gets copy-pasted.

useDebounce(value, wait) gives you a lagging copy of a value — type into the fast one, run effects off the slow one. Perfect for search-as-you-type.

useDebounceFn(fn, wait) debounces an action and hands you { run, cancel, flush }. run always calls your latest callback (no stale closures) and auto-cancels on unmount.
Use flush to commit a pending call early (submit) and cancel to drop it (discard).
The third argument is lodash.debounce options — leading for instant-first-call, maxWait to cap the delay so long bursts still update.
Both are SSR-safe and sit alongside useThrottle/useThrottleFn for the fixed-rate case.

Grab them from @reactuses/core and delete your clearTimeout boilerplate.



Source link

Building a Type-Safe API Layer in Next.js App Router With Zod and Server Actions



Server Actions in Next.js App Router look deceptively simple — write an async function, mark it with ‘use server’, call it from a Client Component. The surface area is small.

The problems surface when you start thinking about validation, error handling, and type safety across the client-server boundary. Without a deliberate approach, you end up with untyped form data on the server, error handling that varies across actions, and client code that can’t trust the shape of what comes back.

Here’s the pattern I landed on for type-safe Server Actions with Zod validation and consistent error handling, from building the generation pipeline powering the free AI wallpaper maker at pixova.io.

The Problem With Naive Server Actions

The simplest Server Action works fine for prototypes:

‘use server’;

export async function submitForm(formData: FormData) {
const prompt = formData.get(‘prompt’) as string;
// No validation, no type safety, any error handling is ad hoc
const result = await generateImage(prompt);
return result;
}

Enter fullscreen mode

Exit fullscreen mode

The issues:

formData.get(‘prompt’) returns string | null | File — the as string cast hides a bug waiting to happen
No validation means invalid input reaches your business logic
Error handling is whatever you add ad hoc to each action

– The return type isn’t defined, so the client has no type information

The Foundation — A Result Type

Start with a discriminated union for action results:

// lib/types/action.ts
export type ActionSuccessT> = {
success: true;
data: T;
};

export type ActionError = {
success: false;
error: string;
fieldErrors?: Recordstring, string()>;
};

export type ActionResultT> = ActionSuccessT> | ActionError;

Enter fullscreen mode

Exit fullscreen mode

Every Server Action returns Promise>. The client always knows whether the action succeeded and what shape the data has.

Adding Zod Validation

// lib/schemas/generate.ts
import { z } from ‘zod’;

export const GenerateSchema = z.object({
prompt: z
.string()
.min(3, ‘Prompt must be at least 3 characters’)
.max(500, ‘Prompt must be under 500 characters’)
.trim(),
aspectRatio: z.enum((‘1:1′, ’16:9’, ‘9:16’, ‘4:5’)).default(‘1:1’),
style: z.string().optional(),
});

export type GenerateInput = z.infertypeof GenerateSchema>;

Enter fullscreen mode

Exit fullscreen mode

The Server Action With Full Type Safety

// app/actions/generate.ts
‘use server’;

import { z } from ‘zod’;
import { GenerateSchema, GenerateInput } from ‘@/lib/schemas/generate’;
import { ActionResult } from ‘@/lib/types/action’;

type GenerateResult = {
jobId: string;
estimatedSeconds: number;
};

export async function generateImageAction(
input: GenerateInput
): PromiseActionResultGenerateResult>> {
// Validate — even though TypeScript already knows the type,
// runtime validation catches anything that slips through
const parsed = GenerateSchema.safeParse(input);

if (!parsed.success) {
return {
success: false,
error: ‘Invalid input’,
fieldErrors: parsed.error.flatten().fieldErrors as Recordstring, string()>,
};
}

try {
const { prompt, aspectRatio, style } = parsed.data;

// Your business logic here
const job = await submitGenerationJob({ prompt, aspectRatio, style });

return {
success: true,
data: {
jobId: job.id,
estimatedSeconds: job.estimatedDuration,
},
};
} catch (error) {
// Log server-side for debugging
console.error(‘Generation failed:’, error);

// Return user-friendly error to client
return {
success: false,
error: ‘Generation failed. Please try again.’,
};
}
}

Enter fullscreen mode

Exit fullscreen mode

The Client-Side Hook

// hooks/useGenerate.ts
‘use client’;

import { useState, useTransition } from ‘react’;
import { generateImageAction } from ‘@/app/actions/generate’;
import { GenerateInput } from ‘@/lib/schemas/generate’;

export function useGenerate() {
const (isPending, startTransition) = useTransition();
const (result, setResult) = useState{ jobId: string } | null>(null);
const (error, setError) = useStatestring | null>(null);

const generate = (input: GenerateInput) => {
setError(null);
setResult(null);

startTransition(async () => {
const response = await generateImageAction(input);

if (response.success) {
setResult({ jobId: response.data.jobId });
} else {
setError(response.error);
}
});
};

return { generate, isPending, result, error };
}

Enter fullscreen mode

Exit fullscreen mode

The Form Component

// components/GenerateForm.tsx
‘use client’;

import { useForm } from ‘react-hook-form’;
import { zodResolver } from ‘@hookform/resolvers/zod’;
import { GenerateSchema, GenerateInput } from ‘@/lib/schemas/generate’;
import { useGenerate } from ‘@/hooks/useGenerate’;

export function GenerateForm() {
const { generate, isPending, error } = useGenerate();

const { register, handleSubmit, formState: { errors } } = useFormGenerateInput>({
resolver: zodResolver(GenerateSchema),
defaultValues: {
aspectRatio: ‘1:1’,
},
});

return (
form onSubmit={handleSubmit(generate)} className=”flex flex-col gap-4″>
div>
textarea
{…register(‘prompt’)}
placeholder=”Describe what you want to generate…”
className=”w-full p-3 rounded-xl border border-border bg-card
text-foreground resize-none h-24 focus:outline-none
focus:ring-2 focus:ring-orange-500″
/>
{errors.prompt && (
p className=”text-sm text-red-500 mt-1″>{errors.prompt.message}p>
)}
div>

select
{…register(‘aspectRatio’)}
className=”p-2 rounded-lg border border-border bg-card text-foreground”
>
option value=”1:1″>Square (1:1)option>
option value=”16:9″>Landscape (16:9)option>
option value=”9:16″>Portrait (9:16)option>
option value=”4:5″>Instagram (4:5)option>
select>

{error && (
p className=”text-sm text-red-500″>{error}p>
)}

button
type=”submit”
disabled={isPending}
className=”px-6 py-3 bg-orange-500 text-white rounded-full
font-medium hover:bg-orange-600 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed”
>
{isPending ? ‘Generating…’ : ‘Generate’}
button>
form>
);
}

Enter fullscreen mode

Exit fullscreen mode

A Reusable Action Wrapper

For larger applications with many actions, a wrapper reduces boilerplate:

// lib/action-wrapper.ts
import { z } from ‘zod’;
import { ActionResult } from ‘./types/action’;

export function createActionTInput, TOutput>(
schema: z.ZodSchemaTInput>,
handler: (input: TInput) => PromiseTOutput>
) {
return async (input: unknown): PromiseActionResultTOutput>> => {
const parsed = schema.safeParse(input);

if (!parsed.success) {
return {
success: false,
error: ‘Validation failed’,
fieldErrors: parsed.error.flatten().fieldErrors as Recordstring, string()>,
};
}

try {
const data = await handler(parsed.data);
return { success: true, data };
} catch (error) {
console.error(‘Action error:’, error);
return {
success: false,
error: error instanceof Error ? error.message : ‘Something went wrong’
};
}
};
}

// Usage
export const generateImageAction = createAction(
GenerateSchema,
async (input) => {
const job = await submitGenerationJob(input);
return { jobId: job.id };
}
);

Enter fullscreen mode

Exit fullscreen mode

The Payoff

With this pattern in place:

Every action has a consistent interface — ActionResult

Validation errors surface to the client with field-level detail
TypeScript knows the exact shape of success and error responses
Error handling is centralized rather than ad hoc per action
Adding a new action means writing the schema and handler — the boilerplate is handled
The upfront investment in the wrapper and types pays off quickly across a codebase with more than a handful of Server Actions.

Testing Server Actions

Server Actions are async functions — they’re straightforward to unit test:

// __tests__/actions/generate.test.ts
import { generateImageAction } from ‘@/app/actions/generate’;

// Mock the generation service
jest.mock(‘@/lib/generation’, () => ({
submitGenerationJob: jest.fn(),
}));

import { submitGenerationJob } from ‘@/lib/generation’;
const mockSubmit = submitGenerationJob as jest.Mock;

describe(‘generateImageAction’, () => {
it(‘returns success with valid input’, async () => {
mockSubmit.mockResolvedValue({ id: ‘job-123’, estimatedDuration: 8 });

const result = await generateImageAction({
prompt: ‘A sunset over mountains’,
aspectRatio: ’16:9′,
});

expect(result.success).toBe(true);
if (result.success) {
expect(result.data.jobId).toBe(‘job-123’);
}
});

it(‘returns validation error for short prompt’, async () => {
const result = await generateImageAction({
prompt: ‘hi’, // Too short
aspectRatio: ‘1:1’,
});

expect(result.success).toBe(false);
if (!result.success) {
expect(result.fieldErrors?.prompt).toBeDefined();
}
});

it(‘returns error when service throws’, async () => {
mockSubmit.mockRejectedValue(new Error(‘Service unavailable’));

const result = await generateImageAction({
prompt: ‘A valid prompt that is long enough’,
aspectRatio: ‘1:1’,
});

expect(result.success).toBe(false);
});
});

Enter fullscreen mode

Exit fullscreen mode

Testing with the ActionResult type makes assertions clean — the discriminated union means TypeScript narrows the type inside the if (result.success) block, so you get full type checking on both success and error paths.

Common Pitfalls

Forgetting that Server Actions run on the server. They don’t have access to window, document, or browser APIs. If you’re calling a Server Action from a component that also uses browser APIs, make sure the action itself doesn’t try to use them.

Not handling revalidatePath or revalidateTag after mutations. If an action mutates data and the page should reflect that, you need to explicitly invalidate the cache:

import { revalidatePath } from ‘next/cache’;

export async function deleteItem(id: string): PromiseActionResultvoid>> {
try {
await db.items.delete(id);
revalidatePath(‘/items’); // Update the cache
return { success: true, data: undefined };
} catch {
return { success: false, error: ‘Failed to delete item’ };
}
}

Enter fullscreen mode

Exit fullscreen mode

Passing complex objects when primitives work. Server Actions serialize arguments across the network. Simple types (strings, numbers, plain objects) serialize cleanly. Class instances, functions, and non-serializable objects don’t. Keep action inputs to JSON-serializable types.



Source link