Preact Interview Questions and Answers

Last updated:

Check out 35 of the most common Preact interview questions, then take an AI-powered practice interview

35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

What ships inside Preact's 3 kB core, and what is deliberately left out?

BasicFundamentals

Answer

The core preact package exports a very small surface: h / createElement, Component, render, hydrate, Fragment, cloneElement, createRef, createContext, toChildArray, isValidElement and the options object. That is it. Everything else lives in separate entry points so the bundler can drop what you do not use.

Hooks are in preact/hooks, the React-compatible API is in preact/compat, JSX automatic runtime in preact/jsx-runtime, SSR in the separate preact-render-to-string package, dev-time warnings in preact/debug, and the devtools bridge in preact/devtools. Signals are a third-party-style addon at @preact/signals. What is deliberately missing from core is the entire React 18 and 19 concurrency layer: there is no fiber tree, no time slicing, no work loop that can be interrupted, no lane priorities, no synthetic event system, no Server Components, no use() hook.

Preact renders depth-first and synchronously once a render is scheduled, and it attaches real addEventListener handlers to the actual DOM nodes. That is why the size claim holds up: React's bulk is largely the scheduler, the event system and the compatibility shims for older browsers. Interviewers ask this to check you understand Preact is not a minified React, it is a different implementation of the same programming model, and the differences show up exactly where the removed machinery used to be.

Key Points

  • Core exports render, hydrate, Component, createContext, Fragment, options
  • Hooks, compat, jsx-runtime, debug and SSR are separate entry points
  • No fiber, no scheduler, no lanes, no synthetic events, no Server Components
  • Rendering is synchronous and depth-first once scheduled
  • Same programming model, different implementation, so behaviour diverges at the edges
💡 Pro Tip: If someone says Preact is just smaller React, ask them what happens to useTransition. That single question separates people who have shipped Preact from people who have read the landing page.
Q2

How do you alias React to preact/compat in Vite, and which aliases are mandatory?

BasicTooling

Answer

There are four aliases that matter, and missing any one of them produces a distinct failure. react and react-dom both point at preact/compat, which re-exports the full React-shaped API on top of Preact primitives. react/jsx-runtime points at preact/jsx-runtime, and this is the one people forget: with the automatic JSX transform, every compiled file imports jsx and jsxs from react/jsx-runtime, so without the alias the build dies with a resolve error for react/jsx-runtime even though you never wrote import React anywhere. react-dom/test-utils points at preact/test-utils so that libraries calling act() get Preact's implementation rather than a missing module. In practice you should not hand-write these at all: install @preact/preset-vite and it configures aliasing, the JSX transform with jsxImportSource set to preact, Fast Refresh through prefresh, and optional prerendering. You still add resolve.dedupe for preact and @preact/signals so a nested dependency cannot drag in a second copy.

On the TypeScript side, set jsx to react-jsx and jsxImportSource to preact in tsconfig, and add a compilerOptions.paths mapping for react and react-dom so editor type checking resolves to Preact's types instead of @types/react. Forgetting the tsconfig half is the classic symptom where the build passes but tsc --noEmit reports hundreds of prop type mismatches.

// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()],
  resolve: {
    // preset-vite sets these, shown here so you know what it does
    alias: {
      react: 'preact/compat',
      'react-dom': 'preact/compat',
      'react-dom/test-utils': 'preact/test-utils',
      'react/jsx-runtime': 'preact/jsx-runtime',
    },
    dedupe: ['preact', '@preact/signals', '@preact/signals-core'],
  },
});

// tsconfig.json (compilerOptions)
// "jsx": "react-jsx",
// "jsxImportSource": "preact",
// "paths": { "react": ["./node_modules/preact/compat/"],
//             "react-dom": ["./node_modules/preact/compat/"] }
💡 Pro Tip: Alias react/jsx-runtime even if your app uses the classic transform. Third-party packages shipped as pre-compiled ESM often use the automatic runtime internally.
Q3

Why does onChange behave differently in Preact, and what breaks when you port a React form?

BasicEvents

Answer

Preact core has no synthetic event layer. When you write onInput or onChange, Preact lowercases the name and calls addEventListener('input', handler) or addEventListener('change', handler) on the real DOM node. So onChange in core Preact is the native change event, which for a text input fires only on blur or Enter, not on every keystroke.

React normalises this: React's onChange is wired to the native input event, which is why React controlled inputs update per character. Port a React form to core Preact untouched and every controlled text field appears frozen, because your state only updates when the field loses focus. The fix in core Preact is to use onInput for text, textarea and contenteditable, and keep onChange only where you genuinely want the native change semantics such as select, checkbox, radio and file inputs.

If you are running preact/compat, it papers over this by remapping onChange to the input event for the relevant element types, which is why a compat-based migration often does not show the bug at all, and then it appears later when someone imports from preact directly in a new file. Related consequences of using real listeners: there is no event pooling, so keeping the event object in a setTimeout works fine; event.target is always the real node; and options.event lets you intercept and rewrite every event before it reaches a handler.

import { useState } from 'preact/hooks';

export function SearchBox() {
  const [q, setQ] = useState('');

  return (
    <form>
      {/* core Preact: onInput fires per keystroke */}
      <input value={q} onInput={(e) => setQ(e.currentTarget.value)} />

      {/* onChange here is the NATIVE change event: fires on blur / Enter */}
      <input value={q} onChange={(e) => setQ(e.currentTarget.value)} />

      {/* select and checkbox are fine with onChange in both worlds */}
      <select onChange={(e) => setQ(e.currentTarget.value)}>
        <option value="jobs">Jobs</option>
        <option value="people">People</option>
      </select>
    </form>
  );
}

Key Points

  • Preact attaches real addEventListener handlers, no synthetic system
  • Core Preact onChange = native change event (blur / Enter for text)
  • Use onInput for text, textarea and contenteditable
  • preact/compat remaps onChange to input, which hides the bug during migration
  • No event pooling, so the event object stays valid asynchronously
Q4

How does Preact decide between setting a DOM property and setting an attribute?

BasicDOM Props

Answer

Preact's setProperty logic is deliberately simple: if the prop name exists as a settable property on the DOM node (roughly, if name in dom) and the element is not an SVG node, Preact assigns the property directly. Otherwise it falls back to setAttribute, and if the value is null, undefined or false it calls removeAttribute. There is no big allowlist of known HTML attributes like React historically maintained.

The practical consequences show up fast. class and className both work, because class is handled explicitly and className maps onto the property. for and htmlFor both work. SVG attributes pass through in their real hyphenated form, so stroke-width, stroke-linecap and clip-path are accepted directly rather than requiring camelCase, though the camelCase forms also work through compat. Any data-* or aria-* prop becomes an attribute.

Most importantly for web components, an unknown prop like user or items on a custom element gets set as a property when that property exists on the element, so you can hand a custom element a real array or object instead of stringifying it into an attribute. React only gained equivalent behaviour in version 19. Two gotchas interviewers like: numeric values in the style object get px appended for non-unitless CSS properties exactly like React, and value / checked on form controls are set as properties, so a controlled input whose state never updates will visibly refuse to change because Preact keeps re-asserting the property on every diff.

// class and className are interchangeable
<div class="card" />
<div className="card" />

// SVG attributes keep their real names
<svg viewBox="0 0 24 24">
  <path d="M4 12h16" stroke-width={2} stroke-linecap="round" />
</svg>

// custom elements receive real objects as properties, not strings
<gs-job-card job={{ id: 42, title: 'Frontend Engineer' }} />

// style numbers get px for non-unitless properties
<div style={{ marginTop: 8, lineHeight: 1.4, zIndex: 10 }} />
// -> margin-top: 8px; line-height: 1.4; z-index: 10;
💡 Pro Tip: Do not sprinkle dangerouslySetInnerHTML to work around attribute problems. If a prop is not landing, log `name in element` in the console: that single expression tells you which branch Preact took.
Q5

What is the difference between render() and hydrate() in Preact?

BasicRendering

Answer

render(vnode, parentDom) performs a full client render into the container. On the first call Preact diffs your virtual tree against nothing, creates every DOM node, and stores a reference to the resulting tree on the container so subsequent render() calls into the same container diff against it rather than wiping it. hydrate(vnode, parentDom) is the SSR counterpart: it walks the existing server-rendered DOM and adopts it. During hydration Preact skips diffing attributes and text content entirely and only attaches event listeners and builds the internal vnode tree, which is what makes hydration cheap.

That skip is the important behavioural detail. Because attributes are not compared, a server and client mismatch in an attribute value is silently kept as whatever the server produced until something triggers a real re-render of that node. Structural mismatches are handled by removing and creating nodes rather than by throwing, so unlike React 18 and 19 you will not get a loud hydration error that discards the whole tree.

That forgiveness is convenient in demos and dangerous in production: a locale-dependent date or a Math.random() key produces a UI that looks right on the server, is wrong on the client, and never warns. Import preact/debug in development to surface the mismatches it can detect. Note also that Preact 10's render() accepts a deprecated third replaceNode argument for replacing an existing DOM subtree; do not use it in new code, it is slated for removal.

import { render, hydrate } from 'preact';
import { App } from './App';

const root = document.getElementById('app');

// SSR or prerendered HTML already present -> adopt it
if (root.firstChild) {
  hydrate(<App />, root);
} else {
  render(<App />, root);
}

// Unmount: render null into the same container
// render(null, root);

Key Points

  • render() diffs against the previous tree stored on the container
  • hydrate() skips attribute and text diffing, attaches listeners only
  • Mismatches are patched silently instead of throwing like React
  • render(null, container) is the unmount idiom
  • The third replaceNode argument of render() is deprecated
Q6

Which hooks does preact/hooks provide, and which React hooks are missing or shimmed?

BasicHooks

Answer

preact/hooks provides useState, useReducer, useEffect, useLayoutEffect, useRef, useMemo, useCallback, useContext, useImperativeHandle, useDebugValue, useId (added in 10.11) and useErrorBoundary, which is Preact-specific and has no React equivalent. The rules are the same as React: top-level only, no conditionals, no loops, because the implementation is a per-component array indexed by call order stored on the component instance under a mangled key. What is not real is the concurrency family. preact/compat exports useTransition, useDeferredValue and startTransition so that React libraries importing them do not crash, but they are compatibility shims, not schedulers. useTransition returns a pending flag that is effectively always false and a callback that runs its argument straight away; useDeferredValue returns the value you gave it; startTransition invokes the callback synchronously.

Nothing is time-sliced, deprioritised or interruptible, because Preact has no fiber tree to interrupt. useSyncExternalStore is provided by recent preact/compat releases and works correctly for external store subscriptions, which matters because Zustand, Redux and Jotai all depend on it. There is no use() hook, no useOptimistic, and no Server Component support. If a candidate claims a Preact app gets React's concurrent rendering benefits, that is the moment the interview turns, because the honest answer is that Preact's answer to responsiveness is doing less work in the first place: smaller trees, memoisation, signals and islands, rather than scheduling work more cleverly.

import { useState, useEffect, useErrorBoundary, useId } from 'preact/hooks';

function Panel() {
  const id = useId();
  const [error, resetError] = useErrorBoundary((err) => reportToSentry(err));
  const [data, setData] = useState(null);

  useEffect(() => {
    const ac = new AbortController();
    fetch('/api/jobs', { signal: ac.signal })
      .then((r) => r.json())
      .then(setData)
      .catch(() => {});
    return () => ac.abort();
  }, []);

  if (error) {
    return <button onClick={resetError}>Retry</button>;
  }
  return <div id={id}>{data ? data.length : 'loading'}</div>;
}
Q7

What does preact/debug do, and why must it be the first import in your entry file?

BasicTooling

Answer

preact/debug installs development-time validation by monkey-patching the options hooks that Preact exposes: it wraps options.vnode, options._diff, options.diffed and options._catchError to run checks on every vnode as it is created and diffed. The checks it adds are genuinely useful. It throws on undefined or invalid vnode types, which turns the classic silent blank screen from a bad import into a readable error naming the component.

It warns about duplicate keys among siblings, about invalid HTML nesting such as a div inside a p, about hooks called outside a component render, about passing a non-object as props, and about improperly nested table markup. It also enables the Preact DevTools bridge so the React DevTools browser extension can inspect your component tree. It must be imported first because the patching only affects vnodes created after the patch runs.

Any module imported above it that creates vnodes at module scope, and more importantly any component tree rendered during that module's evaluation, bypasses the instrumentation entirely. Bundlers hoist imports in source order, so putting the import at the top of your entry file is the guarantee. Critically, never ship it: it adds weight and runtime cost.

The standard pattern is a conditional import in the entry file guarded by import.meta.env.DEV in Vite, so tree shaking removes it from the production bundle. If you only want the DevTools bridge in a staging build without the assertions, import preact/devtools instead, which is the smaller of the two.

// src/main.tsx  (must be the very first import)
if (import.meta.env.DEV) {
  await import('preact/debug');
}

import { render } from 'preact';
import { App } from './App';

render(<App />, document.getElementById('app'));

// Alternative for staging: DevTools bridge only, no assertions
// import 'preact/devtools';
💡 Pro Tip: In a Vite app, a top-level `import 'preact/debug'` guarded by import.meta.env.DEV is dead-code eliminated in the production build, so you get the warnings for free with zero shipped bytes.
Q8

How do you scaffold a Preact project in 2026, and where does Preact CLI fit?

BasicTooling

Answer

The current path is npm init preact, which runs the create-preact scaffolder. It asks whether you want TypeScript, routing via preact-iso, prerendering, and ESLint, then generates a Vite project already wired with @preact/preset-vite. The generated app has an index.tsx entry, jsxImportSource set to preact, Fast Refresh through prefresh, and if you opted in, a prerender export that the build step calls to emit static HTML for each route.

Preact CLI (the preact-cli package with its preact create and preact build commands) is the previous generation, built on Webpack, and is effectively legacy in 2026. You will still encounter it in older codebases, and the interview-relevant fact is what migrating off it costs: Preact CLI shipped an opinionated bundle with automatic code splitting per route, a service worker, and preact/compat aliasing enabled by default, so a straight port to Vite means you have to reintroduce the service worker (vite-plugin-pwa) and be explicit about compat aliasing. Beyond a standalone SPA, Preact is commonly consumed inside other frameworks: Astro through @astrojs/preact with client:load or client:visible island directives, Deno's Fresh which is built on Preact and signals, and Next.js or Remix apps where Preact replaces React purely as a bundle optimisation via webpack or Vite aliases. Knowing which of these your target company runs is worth checking before the interview, because the follow-up questions are completely different for an islands setup versus a classic SPA.

# Modern scaffold (Vite + preset-vite + optional preact-iso)
npm init preact

# Add Preact to an existing Vite app
npm i preact
npm i -D @preact/preset-vite

# Signals
npm i @preact/signals

# SSR / prerender
npm i preact-render-to-string

# Astro island
npx astro add preact

# Legacy, Webpack-based, avoid for new work
# npx preact-cli create default my-app
Q9

How does Preact reconcile keyed and unkeyed children in diffChildren?

BasicReconciliation

Answer

diffChildren walks the new children array and, for each new vnode, looks for a matching old vnode. When children have keys, the match requires both the same key and the same type (the same component function or the same tag name). Preact first tries the old child at the same index as a fast path, and only if that does not match does it scan the remaining old children for a key match, tracking how far it had to look so it can decide whether the node needs to be physically moved in the DOM.

Unkeyed children are matched purely by index and type: position one in the new list pairs with position one in the old list, and if the types differ the old node is unmounted and a new one mounted. That is the source of the classic bug. Render a list of inputs without keys, delete the first item, and every remaining vnode shifts down one index.

Preact happily reuses the DOM nodes and just updates the text, but the component state, the DOM focus and any uncontrolled input value stay attached to the old position, so the user sees the wrong value in the wrong row. Any old vnode with no match after the pass is unmounted, and Preact then calls removeChild or insertBefore only for nodes whose position actually changed, which is why keyed lists that only reorder are cheap. Never use the array index as a key on a reorderable or filterable list: an index key is exactly equivalent to having no key at all for reconciliation purposes, while additionally suppressing the duplicate-key warning that preact/debug would otherwise give you.

// Wrong: index keys collapse to positional matching
{jobs.map((job, i) => <JobRow key={i} job={job} />)}

// Right: stable identity from the data
{jobs.map((job) => <JobRow key={job.id} job={job} />)}

// Keys must be unique among SIBLINGS only, and must survive reorder
function Board({ columns }) {
  return (
    <div class="board">
      {columns.map((col) => (
        <section key={col.id}>
          {col.cards.map((card) => (
            <Card key={card.id} card={card} />
          ))}
        </section>
      ))}
    </div>
  );
}

Key Points

  • Keyed match requires same key AND same vnode type
  • Same-index fast path first, then a scan of remaining old children
  • Unkeyed children match strictly by index and type
  • Index keys are equivalent to no keys for reordering
  • DOM moves happen only for children whose position actually changed
Q10

How do Fragments work in Preact, and what does the jsx-runtime actually compile to?

BasicJSX

Answer

Fragment is a plain function exported from preact that returns props.children, and Preact's diff special-cases it so it never creates a DOM node: its children are diffed directly into the parent DOM element. You can write it as <Fragment>, as the shorthand <>...</>, or return an array from a component, which Preact treats the same way. Because a Fragment has no DOM node of its own, Preact tracks its child DOM nodes on the vnode so it can insert siblings in the right place, which is why a keyed list of Fragments still reorders correctly.

With the automatic JSX transform, which is what @preact/preset-vite and jsxImportSource: preact configure, your JSX does not compile to h() calls at all. It compiles to imports of jsx, jsxs and Fragment from preact/jsx-runtime (or preact/jsx-dev-runtime in development, which additionally carries source location for error messages). jsx is used for a single child and jsxs for multiple children, a signal to the runtime that the children array is statically known and does not need cloning. The classic transform, jsx: react and jsxFactory: h with jsxFragmentFactory: Fragment, still works and you will see it in older projects, but it forces you to keep h in scope in every file.

A common build failure is mixing the two: a tsconfig set to react-jsx while a Babel config still injects a pragma comment, producing an app where some files reference an undefined h. Pick one, set it in tsconfig and in the bundler, and delete stray /** @jsx h */ pragmas.

// Automatic runtime (recommended)
// tsconfig: "jsx": "react-jsx", "jsxImportSource": "preact"
export function Row() {
  return (
    <>
      <td>Frontend Engineer</td>
      <td>Bengaluru</td>
    </>
  );
}
// compiles to: import { jsxs as _jsxs, Fragment as _Fragment } from 'preact/jsx-runtime';

// Classic runtime (legacy projects)
/** @jsx h */
/** @jsxFrag Fragment */
import { h, Fragment } from 'preact';

// Returning an array works like a Fragment
function Cells() {
  return [<td>A</td>, <td>B</td>];
}
Q11

How does Preact batch state updates, and when does a component actually re-render?

BasicRendering

Answer

Calling setState or a hook setter does not render anything immediately. Preact marks the component instance dirty and pushes it into a module-level rerenderQueue via enqueueRender. If the component is already dirty it is not enqueued twice, which is why calling five setters in one handler produces one render.

Flushing is scheduled through options.debounceRendering if you have set it, otherwise a resolved Promise microtask, with a setTimeout fallback for environments without Promise. Before flushing, the queue is sorted by vnode depth so that ancestors render before descendants, which prevents a parent render from redundantly re-rendering a child that was independently queued. Two consequences matter in interviews.

First, batching in Preact is not limited to event handlers the way legacy React 17 batching was: because the flush is a microtask, updates inside promises, fetch callbacks, setTimeout bodies and native event listeners are all batched too, so Preact 10 has effectively always had what React called automatic batching in 18. Second, reading DOM immediately after setState gives you the stale DOM, because the microtask has not run. If you need the committed DOM, read it in useLayoutEffect, which runs synchronously after commit and before paint, not in useEffect, which is deferred.

Preact also bails out of re-rendering when a useState setter is called with a value that is Object.is-equal to the current one, so setCount(count) in a loop is free. The hook setter also accepts an updater function, and you should use it whenever the next value depends on the previous one, since the queued closure would otherwise capture a stale value.

import { useState, useLayoutEffect, useRef } from 'preact/hooks';

function Counter() {
  const [n, setN] = useState(0);
  const boxRef = useRef(null);

  function bump() {
    setN(n + 1);
    setN(n + 1);        // still 1: both closures read the same n
    setN((p) => p + 1); // updater form is safe
    console.log(boxRef.current.textContent); // STALE: flush is a microtask
  }

  useLayoutEffect(() => {
    // runs after commit, before paint: safe place to measure
    console.log(boxRef.current.getBoundingClientRect().height);
  }, [n]);

  return <div ref={boxRef} onClick={bump}>{n}</div>;
}
💡 Pro Tip: options.debounceRendering = requestAnimationFrame is a real tuning knob for render-heavy dashboards, but it also makes tests flaky. Set it back to the default in your test setup or use act() from preact/test-utils.
Q12

How do refs work in Preact, including callback refs and forwardRef?

BasicRefs

Answer

createRef() and useRef() both return a plain object with a current property. Passing it as the ref prop on a host element makes Preact assign the DOM node to ref.current after the element is created, and set it back to null on unmount. A callback ref, ref={(node) => ...}, is invoked with the node on mount and with null on unmount, and Preact applies the same rule React does: if you pass a new inline arrow function on every render, the old ref is called with null and the new one with the node on every single render, which turns an innocent-looking ref into a per-render churn source.

Wrap it in useCallback or hoist it. Function components cannot receive a ref directly, because there is no instance to expose. Core Preact does not export forwardRef at all; it comes from preact/compat, and it also gives you useImperativeHandle so a child can expose a controlled method surface instead of leaking its DOM node.

A pattern worth knowing for library-style code: in core Preact you can simply pass the ref under a differently named prop, because Preact does not strip an arbitrary prop the way React strips ref, so forwardRef is only strictly required when you are mimicking a React API. useRef is also the standard escape hatch for mutable values that must survive renders without triggering one: interval IDs, AbortControllers, previous-value tracking, IntersectionObserver instances. Mutating ref.current never schedules a render, which is exactly why refs are the wrong place for anything the UI displays.

import { useRef, useCallback, useImperativeHandle } from 'preact/hooks';
import { forwardRef } from 'preact/compat';

const SearchInput = forwardRef(function SearchInput(props, ref) {
  const inner = useRef(null);
  useImperativeHandle(ref, () => ({
    focus: () => inner.current?.focus(),
    clear: () => { inner.current.value = ''; },
  }), []);
  return <input ref={inner} {...props} />;
});

function Page() {
  const box = useRef(null);

  // stable callback ref: does NOT churn on every render
  const measure = useCallback((node) => {
    if (node) console.log(node.offsetWidth);
  }, []);

  return (
    <div ref={measure}>
      <SearchInput ref={box} placeholder="Search jobs" />
      <button onClick={() => box.current.focus()}>Focus</button>
    </div>
  );
}
Q13

How does createContext work in Preact, and how does it differ from React's?

BasicContext

Answer

createContext(defaultValue) returns an object with Provider and Consumer, and useContext(Ctx) reads the nearest provider value. The API matches React, but the propagation mechanism is different and that difference is the interview point. React walks the tree from the provider down, marking consumers for update.

Preact's Provider maintains an explicit subscriber list: when a component calls useContext, it registers itself with the provider, and when the provider's value prop changes, the provider directly enqueues a re-render on each subscribed component. That is why context updates in Preact reach consumers even through a component that returned false from shouldComponentUpdate or is wrapped in memo, and why they do not require re-rendering the intermediate tree at all. It is essentially a built-in pub/sub, closer to how libraries like react-redux implement subscription than to React's own propagation.

The practical gotchas are the familiar ones. Passing an object literal as value means a new reference on every provider render, so every consumer re-renders even when nothing meaningful changed; memoise it with useMemo. Splitting a fat context into a rarely-changing config context and a frequently-changing state context is the standard fix for context-driven render storms.

The defaultValue is only used when there is no matching Provider above, which makes it a poor error surface: prefer a sentinel and throw from a custom hook so a missing provider fails loudly rather than silently rendering with defaults. Preact 10 also still supports the legacy childContextTypes-free getChildContext() on class components, which some very old codebases rely on.

import { createContext } from 'preact';
import { useContext, useMemo, useState } from 'preact/hooks';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  // memoise: otherwise every consumer re-renders on each provider render
  const value = useMemo(() => ({ user, setUser }), [user]);
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
  return ctx;
}

Key Points

  • Provider keeps an explicit subscriber list and enqueues consumers directly
  • Updates pass through memo and shouldComponentUpdate barriers
  • Memoise the value object or every consumer re-renders
  • Split fast-changing and slow-changing state into separate contexts
  • defaultValue hides missing providers; throw from a custom hook instead
Q14

What is the difference between useEffect and useLayoutEffect in Preact, and when does each flush?

BasicHooks

Answer

Both register a callback that runs after the component commits, with an optional cleanup returned from the callback and an optional dependency array controlling when it re-runs. The difference is timing. useLayoutEffect callbacks are invoked synchronously inside commitRoot, immediately after the DOM has been mutated and before the browser paints. useEffect callbacks are deferred: Preact collects them and flushes them asynchronously, using requestAnimationFrame with a setTimeout race so that effects still run in background tabs where rAF never fires. That fallback exists because a background tab that never flushed effects would leak subscriptions and never fire analytics.

The practical rule is that anything which reads layout and then writes to the DOM must be in useLayoutEffect, because doing it in useEffect means the user sees one painted frame with the wrong geometry, the classic tooltip-flash or scroll-jump bug. Everything else, meaning data fetching, subscriptions, logging, timers, belongs in useEffect so it does not block paint. Two Preact-specific notes.

First, useLayoutEffect during server rendering has no DOM, so preact-render-to-string simply never runs effects at all, and unlike React it does not print a warning about useLayoutEffect on the server, which means SSR-unsafe layout code fails silently and only breaks on hydration. Second, cleanup ordering: on an update, Preact runs the previous cleanup before the new callback for the same hook slot, and on unmount it runs cleanups depth-first. If a cleanup throws, it is routed through the nearest error boundary rather than crashing the unmount, but the remaining cleanups in that component are skipped, which is a real source of leaked event listeners.

import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';

function Tooltip({ anchorRef, text }) {
  const [pos, setPos] = useState({ top: 0, left: 0 });
  const tipRef = useRef(null);

  // measure + position BEFORE paint, otherwise the tooltip visibly jumps
  useLayoutEffect(() => {
    const a = anchorRef.current.getBoundingClientRect();
    const t = tipRef.current.getBoundingClientRect();
    setPos({ top: a.top - t.height - 8, left: a.left });
  }, [text]);

  // non-visual work stays in useEffect so it never blocks paint
  useEffect(() => {
    const id = setTimeout(() => track('tooltip_shown'), 500);
    return () => clearTimeout(id);
  }, [text]);

  return <div ref={tipRef} style={{ position: 'fixed', ...pos }}>{text}</div>;
}
Q15

How do @preact/signals skip the component re-render entirely when a signal is used in JSX?

IntermediateSignals

Answer

A signal is a reactive box created with signal(initial); you read it with .value and write it with .value = next. Any code that reads .value inside a tracking scope becomes a subscriber of that signal, and the graph is push-based: writing a signal notifies exactly its dependents, with no diffing involved. The Preact adapter wires this into rendering through the options hooks: it wraps each component render in an effect scope, so if a component reads someSignal.value during render, that component is subscribed and re-renders when the signal changes, without any parent needing to know.

The optimisation people ask about is the direct JSX binding. When you put the signal object itself into JSX, as in <span>{count}</span> rather than <span>{count.value}</span>, the adapter recognises the signal as a child, renders a text node, and subscribes that text node directly to the signal. Updating count then writes to Text.data with no vnode created, no diff run and no component function invoked.

The same applies to DOM element props in recent versions, so class={theme} on a host element updates the attribute directly. The trap is that this only works for host elements and text positions. Pass a signal as a prop to a custom component and that component must read .value itself, at which point the component re-renders normally. Interviewers probe the difference because it is the whole performance argument: signals do not make renders faster, they remove renders, and only if you keep the .value read as close to the DOM as possible.

import { signal, computed, effect } from '@preact/signals';

const count = signal(0);
const doubled = computed(() => count.value * 2);

effect(() => {
  document.title = `Count ${count.value}`;
});

function Counter() {
  // NO .value here: Preact binds the text node straight to the signal,
  // so Counter() never runs again when count changes.
  return (
    <button onClick={() => count.value++}>
      {count} / {doubled}
    </button>
  );
}

function Slow() {
  // Reading .value in render subscribes the COMPONENT: full re-render.
  return <p>{count.value}</p>;
}

Key Points

  • signal(), computed(), effect(), batch(), untracked() from @preact/signals
  • Reading .value during render subscribes the whole component
  • Passing the signal object into JSX binds a Text node directly, zero renders
  • Prop binding on host elements gets the same treatment in recent versions
  • Push-based graph: no diff, no scheduler, updates are synchronous by default
Q16

When should you use useSignal and useComputed instead of useState and useMemo?

IntermediateSignals

Answer

useSignal(initial) creates a signal that is stable for the lifetime of the component, equivalent to useRef(signal(initial)).current but without the awkward wrapping. useComputed(fn) creates a computed whose dependencies are tracked automatically, and useSignalEffect(fn) is an effect with automatic dependency tracking that cleans itself up on unmount. The reason to reach for them over useState and useMemo is that they remove the dependency array entirely and, more importantly, they let you localise updates. With useState, any change forces the whole component function to run again and its subtree to be diffed.

With useSignal, if the only consumer of the value is a text node or a host element attribute, the component body never executes. In a form with thirty fields, that difference is the gap between typing feeling instant and typing feeling laggy on a mid-range Android phone, which is the exact scenario Indian product teams optimise for. The cost is that signals are mutable references shared by identity, so they do not compose with React-style patterns that assume value semantics, and they are easy to misuse: reading .value in a render path you thought was cheap silently converts a fine-grained update back into a full component re-render. Rules that hold up in review: keep .value reads as low in the tree as possible, use .peek() when you need the current value inside a handler without subscribing, prefer useComputed over useMemo when the inputs are signals because it invalidates lazily and only recomputes when actually read, and keep useState for values that genuinely need to drive a structural re-render such as which branch of a conditional renders.

import { useSignal, useComputed, useSignalEffect } from '@preact/signals';

function SalaryFilter() {
  const min = useSignal(600000);
  const max = useSignal(1800000);
  const label = useComputed(() => `₹${min.value / 100000}-${max.value / 100000} LPA`);

  useSignalEffect(() => {
    // auto-tracked, auto-disposed on unmount, no dependency array
    localStorage.setItem('salaryRange', JSON.stringify([min.value, max.value]));
  });

  function submit() {
    // peek() reads without subscribing this scope
    search({ min: min.peek(), max: max.peek() });
  }

  return (
    <div>
      <input type="range" value={min} onInput={(e) => (min.value = +e.currentTarget.value)} />
      <span>{label}</span>
      <button onClick={submit}>Apply</button>
    </div>
  );
}
💡 Pro Tip: Use .peek() inside event handlers and callbacks. Using .value there in a component that also renders it is harmless, but inside an effect it creates a dependency you did not intend and produces effect loops.
Q17

What does batch() do in @preact/signals, and when do you still get extra work?

IntermediateSignals

Answer

Signal writes are synchronous by default: assigning .value immediately notifies dependents and runs any effects. Write three signals in a row and you run the dependent effects three times. batch(fn) defers notification until the callback returns, so all writes inside it produce a single notification pass, and nested batch calls collapse into the outermost one. Computed values inside a batch are still readable and correct, because computeds are lazy and pull their inputs when read rather than being pushed to, so reading a computed mid-batch forces an up-to-date recomputation without flushing effects.

Where people still get extra work: first, writes that happen inside a Preact event handler are not automatically batched by the framework, because Preact's own batching applies to component re-renders in the rerenderQueue, not to signal effects. If your handler mutates several signals that a shared effect depends on, wrap it in batch() explicitly. Second, an effect that both reads and writes signals can re-enter itself; the library guards against infinite loops by throwing a cycle detection error, and seeing that error in production usually means an effect is being used where a computed belongs.

Third, if a component reads .value during render, batching signal writes does not reduce component renders below one, it only removes the redundant extras. Fourth, untracked(fn) is the tool for reading signals inside an effect without creating a dependency, and it is distinct from peek() only in that it covers a whole block rather than a single read.

import { signal, computed, effect, batch, untracked } from '@preact/signals-core';

const firstName = signal('Saksham');
const lastName = signal('Sandhu');
const full = computed(() => `${firstName.value} ${lastName.value}`);

effect(() => console.log('render name:', full.value));

// 2 effect runs
firstName.value = 'Akshat';
lastName.value = 'Sharma';

// 1 effect run
batch(() => {
  firstName.value = 'Devansh';
  lastName.value = 'Kumar';
});

effect(() => {
  save(firstName.value);           // tracked dependency
  untracked(() => audit(lastName.value)); // read without subscribing
});
Q18

Preact has no fiber. What do startTransition and useDeferredValue actually do in preact/compat?

IntermediateConcurrency

Answer

They exist so that React libraries importing them do not crash, and they are approximately no-ops. startTransition(cb) calls cb synchronously. useTransition() returns a pending flag and a start function; the pending flag does not track real background work the way React's does. useDeferredValue(v) returns v. There is no priority lane, no interruption, no tearing-avoidance machinery, because Preact's renderer walks the tree depth-first and synchronously once the microtask flush begins; there is nothing to yield to. Saying this plainly in an interview is the correct answer, and the follow-up is always what you do instead.

Preact's strategy is to reduce the amount of work rather than reschedule it. Concretely: keep the update local so fewer components render, which is exactly what signals buy you; debounce or throttle the input that drives an expensive render, using a stored timeout in a ref rather than pretending a transition will absorb it; virtualise long lists so the render cost is bounded by viewport size rather than data size; move genuinely expensive computation into a Web Worker and post results back, since blocking work in Preact blocks the frame with no scheduler to save you; and split routes so the initial parse cost stays small. If a component subtree is unavoidably expensive and you need the input to stay responsive, the honest Preact pattern is to render the cheap part immediately and mount the expensive part behind a requestIdleCallback or a rAF-deferred state flip, which you write yourself in about ten lines. Interviewers use this question to check whether a candidate migrated a React 18 app to Preact without auditing what they lost.

import { useState, useRef, useEffect } from 'preact/hooks';

// Hand-rolled deferral: what useDeferredValue would have done for you in React
function useDeferred(value, ms = 120) {
  const [deferred, setDeferred] = useState(value);
  const t = useRef(0);

  useEffect(() => {
    clearTimeout(t.current);
    t.current = setTimeout(() => setDeferred(value), ms);
    return () => clearTimeout(t.current);
  }, [value, ms]);

  return deferred;
}

function JobSearch() {
  const [q, setQ] = useState('');
  const slowQ = useDeferred(q); // input stays responsive
  return (
    <>
      <input value={q} onInput={(e) => setQ(e.currentTarget.value)} />
      <ExpensiveResults query={slowQ} />
    </>
  );
}

Key Points

  • startTransition runs synchronously; useDeferredValue returns its argument
  • useTransition's pending flag does not reflect real background work
  • No lanes, no interruption, no time slicing, because there is no fiber tree
  • Mitigate with signals, virtualisation, debouncing, workers and code splitting
  • Audit every concurrency API before migrating a React 18 or 19 app
Q19

How do you server-render Preact, and what is the difference between renderToString and renderToStringAsync?

IntermediateSSR

Answer

Server rendering lives in a separate package, preact-render-to-string, not in core. renderToString(vnode) is a synchronous, single-pass string builder: it walks the tree, calls component functions, and concatenates markup. It never runs useEffect or useLayoutEffect, it does run the component body and useMemo, and if a component throws a promise (the Suspense protocol used by lazy()), the synchronous renderer cannot wait for it and falls back to rendering the Suspense boundary's fallback. renderToStringAsync(vnode) returns a promise and does handle thrown promises: it awaits them and re-renders that subtree with the resolved value, so lazy components and data-fetching-by-suspension actually produce real markup instead of spinners. The cost is that you buffer the whole document before sending anything, so time to first byte is the full render time.

For streaming, the package exposes renderToPipeableStream for Node streams and renderToReadableStream for Web streams (Cloudflare Workers, Deno, edge runtimes), which flush the shell first and push suspended content as it resolves. renderToStaticMarkup is the variant for output you will never hydrate, such as transactional email HTML, and it skips the hydration bookkeeping. Two production gotchas. First, SSR runs in Node, so any component touching window, document or localStorage at module scope or in the render body crashes the request, not just the component.

Second, escaping: Preact escapes text content and attribute values, but dangerouslySetInnerHTML is passed through verbatim, so any server-rendered user content in that prop is a stored XSS hole. Sanitise before it reaches the vnode, never after.

import { renderToStringAsync } from 'preact-render-to-string';
import { renderToReadableStream } from 'preact-render-to-string/stream';
import { App } from './App';

// Node/Express: buffered, Suspense-aware
app.get('*', async (req, res) => {
  const body = await renderToStringAsync(<App url={req.url} />);
  res.status(200).send(
    `<!DOCTYPE html><html><head><link rel="stylesheet" href="/app.css"></head>` +
    `<body><div id="app">${body}</div><script type="module" src="/client.js"></script></body></html>`
  );
});

// Edge runtime: streamed shell first
export default {
  fetch(request) {
    const stream = renderToReadableStream(<App url={request.url} />);
    return new Response(stream, { headers: { 'content-type': 'text/html' } });
  },
};
💡 Pro Tip: renderToString is genuinely fast because it is just string concatenation, but it is CPU-bound and blocks the Node event loop. Put a per-request timeout in front of it, or one pathological page freezes every concurrent request on that process.
Q20

Why does Preact hydration not warn about mismatches, and how do you catch them before users do?

IntermediateSSR

Answer

hydrate() takes a deliberate shortcut: while adopting the server DOM, Preact skips diffing attributes and text content and only attaches event handlers and builds the internal vnode tree. That is what makes hydration cheap in a 3 kB library, but it means a mismatched attribute or a differing text node is simply left as the server rendered it. There is no console warning in production and no fallback-to-client-render like React 18 and 19 perform.

Structural mismatches, where the server produced a different element type or a different number of children, are handled by unmounting and creating nodes on the fly, again silently. The failure mode in production is a UI that is subtly and persistently wrong: a timestamp rendered in the server's UTC while the user is in IST, a feature flag evaluated server-side and never corrected, a price formatted with the wrong locale, all of which look fine until the first real re-render of that node hours later. Catching them requires deliberate effort.

Import preact/debug in development, which surfaces the mismatches it can detect. Write a hydration smoke test that renders each critical route with renderToStringAsync, hydrates it into jsdom, forces a top-level re-render, and asserts the DOM is byte-identical before and after, since any divergence there is a mismatch. Eliminate the usual causes structurally: never call Date.now(), Math.random(), or read window during render; pass server-computed values down as serialised props on a script tag rather than recomputing them client-side; and gate anything genuinely client-only behind a mounted flag set in useEffect so the first client render matches the server exactly.

import { useState, useEffect } from 'preact/hooks';

// Safe pattern: first client render matches the server byte for byte
function LocalTime({ iso }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  // server and first client pass both render the ISO string
  if (!mounted) return <time dateTime={iso}>{iso}</time>;

  return (
    <time dateTime={iso}>
      {new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata' })}
    </time>
  );
}

// Unsafe: server UTC vs client IST, hydrate() will never correct it
// function Bad({ iso }) { return <time>{new Date(iso).toLocaleString()}</time>; }
Q21

What actually stops a subtree from re-rendering in Preact: memo, shouldComponentUpdate or useMemo?

IntermediatePerformance

Answer

They operate at three different levels and people conflate them constantly. memo(Component, areEqual) comes from preact/compat and wraps a function component so that when the parent re-renders, Preact compares the new props against the old with a shallow equality check (or your custom comparator) and, if equal, reuses the previous vnode output without calling the component function. shouldComponentUpdate is the class-component equivalent, returning false to skip both render and the diff of that subtree; Preact respects it exactly as React does, and PureComponent from compat implements it as a shallow prop and state comparison. useMemo does not stop any render at all: it caches a computed value across renders of the same component. The most common wasted optimisation in Preact code review is a useMemo around a cheap expression, sitting inside a component that re-renders on every parent update anyway, providing zero benefit while adding a dependency array to keep correct. What memo actually needs to work is referentially stable props, which means the parent must wrap callbacks in useCallback and objects and arrays in useMemo, otherwise every render produces new references and the shallow comparison always fails.

Two Preact-specific caveats. First, context updates bypass memo entirely, because the provider enqueues subscribed consumers directly rather than propagating through the tree, so a memo boundary will not shield a consumer. Second, signals bypass the question altogether: if the changing value is a signal bound into JSX, no component re-renders, so you do not need memo in the first place. In a Preact codebase, reaching for signals is usually a better answer than blanketing the tree in memo.

import { memo } from 'preact/compat';
import { useCallback, useMemo, useState } from 'preact/hooks';

const JobRow = memo(function JobRow({ job, onApply }) {
  return <li onClick={() => onApply(job.id)}>{job.title}</li>;
});

function JobList({ jobs }) {
  const [tick, setTick] = useState(0);

  // Without useCallback, a NEW onApply each render defeats memo entirely
  const onApply = useCallback((id) => apply(id), []);

  // Custom comparator when shallow equality is too strict
  const sorted = useMemo(() => [...jobs].sort((a, b) => b.postedAt - a.postedAt), [jobs]);

  return (
    <ul onMouseMove={() => setTick((t) => t + 1)}>
      {sorted.map((job) => <JobRow key={job.id} job={job} onApply={onApply} />)}
    </ul>
  );
}

Key Points

  • memo and shouldComponentUpdate skip renders; useMemo only caches a value
  • memo needs referentially stable props, so pair it with useCallback
  • Context updates reach consumers through memo boundaries
  • Signals remove the render instead of skipping it
  • Profile before memoising; the comparison itself is not free
Q22

How do you test Preact components with Vitest and @testing-library/preact?

IntermediateTesting

Answer

The standard 2026 stack is Vitest with the jsdom or happy-dom environment, @preact/preset-vite in the Vitest config so JSX and aliasing work identically to the app build, and @testing-library/preact for render, screen, fireEvent, waitFor and cleanup. Three configuration details cause most of the pain. First, the alias set must be identical to the app's: if react resolves to preact/compat in the app but to the real react in tests, any dependency importing React loads a second framework and your assertions test something that does not exist in production.

Second, resolve.dedupe with preact prevents a hoisted duplicate; without it you get errors of the shape Cannot read properties of undefined while a hook tries to read its internal hook list off a null current component. Third, you need cleanup between tests, which @testing-library/preact registers automatically when globals is enabled, and forgetting it leaves mounted trees whose effects keep firing into later tests. For anything that triggers a render outside of Testing Library's own helpers, wrap it in act() from preact/test-utils, because Preact's flush is a microtask and an unwrapped assertion runs before the DOM updates. preact/test-utils also exports setupRerender, which returns a function that flushes the queue synchronously, handy in low-level unit tests where you do not want async assertions.

Write queries against accessible roles and labels rather than test IDs or class names, so a refactor from a div to a button does not break the suite. For anything visual or interaction-heavy, Playwright against the built bundle catches the class of failure unit tests structurally cannot: a broken compat alias in the production Rollup config.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()],
  resolve: { dedupe: ['preact', '@preact/signals'] },
  test: { environment: 'jsdom', globals: true, setupFiles: ['./test/setup.ts'] },
});

// Counter.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/preact';
import { act } from 'preact/test-utils';
import { Counter } from '../src/Counter';

it('increments on click', async () => {
  render(<Counter />);
  await act(() => fireEvent.click(screen.getByRole('button', { name: /add/i })));
  await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument());
});
💡 Pro Tip: Import @testing-library/preact, not @testing-library/react, even in a compat project. The React version calls react-dom internals that preact/compat does not expose, and the failure message is unhelpful.
Q23

Two copies of Preact ended up in the bundle. How do you diagnose and fix it?

IntermediateBuild

Answer

This is the single most common production incident in a Preact codebase, and the symptoms are misleading. Hooks throw errors about reading a property off undefined or null, because preact/hooks resolves the current rendering component from a module-level variable in its own copy of core, and the component was rendered by the other copy. Context returns the default value even though a Provider is clearly above, because the two copies have separate context registries.

Signals stop updating components. Everything works in dev and breaks only in the production build, or the reverse. Diagnose it by asking the bundle rather than guessing: npm ls preact or pnpm why preact shows whether a dependency pinned a different range, and rollup-plugin-visualizer or vite build with a sourcemap analysis shows two separate preact/dist chunks.

A quick runtime check is to import options from preact in two different files and compare identity, or in dev to log the preact module path. The fixes, in order: add resolve.dedupe with preact, preact/hooks, @preact/signals and @preact/signals-core to the Vite config; move preact to peerDependencies plus devDependencies in any library you publish so consumers supply it; in pnpm workspaces set the dependency to a single version across packages, since pnpm's strict node_modules layout makes duplicates far easier to create than npm's hoisting; and if a stubborn transitive dep pins an old range, use npm overrides or pnpm.overrides to force one version. For libraries you author, the deeper fix is never bundling Preact into your dist output: mark it external in your Rollup or tsup config.

# 1. Prove it
npm ls preact
pnpm why preact

# 2. Force one copy (vite.config.ts)
#   resolve: { dedupe: ['preact', 'preact/hooks', '@preact/signals', '@preact/signals-core'] }

# 3. package.json overrides when a transitive dep pins an old range
#   "overrides":     { "preact": "^10.26.0" }        // npm
#   "pnpm": { "overrides": { "preact": "^10.26.0" } } // pnpm

# 4. If you PUBLISH a Preact library, never bundle preact
#   "peerDependencies": { "preact": ">=10" }
#   rollup: external: ['preact', 'preact/hooks', 'preact/compat']
Q24

What does preact-iso give you, and how does it differ from react-router?

IntermediateRouting

Answer

preact-iso is the official lightweight routing and isomorphic rendering package, around one kilobyte, and it is what create-preact scaffolds when you opt into routing. It exports LocationProvider, Router, Route, useLocation, useRoute, lazy, ErrorBoundary, hydrate and prerender. Routing is intentionally minimal: you nest Route elements with a path pattern using :param and :param? and a rest segment, and the Router picks the first match; there is no nested route configuration object, no loader or action layer, no data router.

That is the core difference from react-router, which since version 6.4 is really a data-fetching framework with loaders, actions, deferred data and its own router objects. If you need those, react-router works under preact/compat, but you have just spent a large fraction of your bundle budget on the router, which usually defeats the reason you picked Preact. preact-iso's other half is the isomorphic part. Its lazy() suspends during SSR in a way renderToStringAsync understands, so lazily loaded routes still produce real server markup instead of a fallback.

Its prerender() helper walks your app, discovers links, and is what @preact/preset-vite's prerender option calls at build time to emit static HTML per route, giving you a fast-first-paint static site with client-side navigation after hydration. The Router also supports a scroll-restoration and a route-change callback via onRouteChange and onLoadEnd, which is where you fire analytics page views. For an SEO-driven Indian consumer product, the prerender path is usually the point: static HTML for crawlers, tiny hydration payload for users on 4G.

import { LocationProvider, Router, Route, lazy, ErrorBoundary, useLocation } from 'preact-iso';

const JobDetail = lazy(() => import('./routes/JobDetail'));

export function App() {
  return (
    <LocationProvider>
      <ErrorBoundary onError={(e) => reportToSentry(e)}>
        <Router onRouteChange={(url) => track('pageview', { url })}>
          <Route path="/" component={Home} />
          <Route path="/jobs/:slug" component={JobDetail} />
          <Route path="/company/:id/:tab?" component={Company} />
          <Route default component={NotFound} />
        </Router>
      </ErrorBoundary>
    </LocationProvider>
  );
}

function Back() {
  const { route, path } = useLocation();
  return <button onClick={() => route('/jobs')}>Back from {path}</button>;
}
Q25

How does Preact interoperate with custom elements, and where does that break?

IntermediateWeb Components

Answer

Preact's property-versus-attribute heuristic makes custom element consumption natural: because it sets a DOM property whenever the property exists on the element, you can pass arrays, objects and functions to a custom element directly and the element receives the real value rather than [object Object]. Events are equally easy in one direction and awkward in the other. Preact lowercases the event name and calls addEventListener, so onClick becomes click, but a custom element dispatching a CustomEvent named item-selected cannot be captured as onItem-selected.

The idiomatic solution is a ref plus an explicit addEventListener in a useEffect, or an attribute-cased handler if the element author followed the on-prefixed property convention. Going the other direction, preact-custom-element (the register helper) wraps a Preact component as a real custom element, mapping observed attributes to props, and it is a genuinely good way to ship a widget that must embed into a WordPress site, an Angular app or a legacy jQuery page. Where it breaks: attributes are always strings, so anything richer has to be set as a property from the host page or parsed inside the component; the Shadow DOM boundary blocks your global stylesheet, so you either adopt constructable stylesheets or render light DOM by not attaching a shadow root; and events crossing the shadow boundary need composed: true to be observable outside. Also note that custom elements upgrade asynchronously, so a property set by Preact before the element definition loads is shadowed by the class field once it upgrades unless the element author implemented the standard property-shadowing dance in connectedCallback.

import { useEffect, useRef } from 'preact/hooks';
import register from 'preact-custom-element';

// Consuming a custom element: objects pass through as properties
function Page({ jobs }) {
  const el = useRef(null);

  useEffect(() => {
    const node = el.current;
    const onPick = (e) => console.log(e.detail.id);
    node.addEventListener('item-selected', onPick);
    return () => node.removeEventListener('item-selected', onPick);
  }, []);

  return <gs-job-picker ref={el} items={jobs} compact />;
}

// Publishing a Preact component AS a custom element
function Badge({ score }) {
  return <span class="badge">{score}</span>;
}
register(Badge, 'gs-badge', ['score'], { shadow: true });
💡 Pro Tip: Boolean attributes on custom elements are a trap. In HTML, the presence of the attribute means true, so passing active={false} must remove the attribute; Preact does that for null, undefined and false, but a string 'false' is truthy and will silently enable the feature.
Q26

How do error boundaries work in Preact, and what does useErrorBoundary give you over componentDidCatch?

IntermediateError Handling

Answer

Preact routes render-time errors through options._catchError, which walks up the vnode tree looking for the nearest component that implements componentDidCatch or getDerivedStateFromError, exactly as React does, and calls it with the error. If no boundary is found, the error is rethrown and the render is aborted, leaving the DOM in whatever partial state the diff reached. Preact adds useErrorBoundary(callback), a hook with no React equivalent, which returns a tuple of the caught error and a reset function.

That is a real ergonomics win: in React you must write a class component to catch errors, so every codebase carries one boilerplate ErrorBoundary class; in Preact a function component can be its own boundary. The important caveat is the same as React's, and it is the follow-up interviewers ask. Error boundaries only catch errors thrown during render, in lifecycle methods, and in the constructor of child components.

They do not catch errors in event handlers, in asynchronous callbacks such as setTimeout or a promise rejection, or in errors thrown by the boundary component itself. For those you need a try/catch in the handler, a window.onerror listener and an unhandledrejection listener. In production the boundary should do three things: report to your error tracker with the component stack, render a scoped fallback rather than blanking the page, and offer a reset so a transient failure does not strand the user. Placing a single boundary at the root is the anti-pattern, because one broken widget then takes down the whole route; wrap each independently failable region instead.

import { useErrorBoundary } from 'preact/hooks';

function Boundary({ name, children }) {
  const [error, reset] = useErrorBoundary((err, info) => {
    reportToSentry(err, { region: name, componentStack: info?.componentStack });
  });

  if (error) {
    return (
      <div class="fallback">
        <p>{name} could not load.</p>
        <button onClick={reset}>Try again</button>
      </div>
    );
  }
  return children;
}

// Async and handler errors bypass boundaries entirely
window.addEventListener('unhandledrejection', (e) => reportToSentry(e.reason));
window.addEventListener('error', (e) => reportToSentry(e.error));

// Scope boundaries per region, not once at the root
// <Boundary name="recommendations"><Recommendations /></Boundary>
Q27

How do you prove and defend the bundle-size win, and what does preact/compat actually cost?

IntermediatePerformance

Answer

Preact core is around 3 kB gzipped, preact/hooks adds roughly a kilobyte, and preact/compat adds a few more because it implements memo, forwardRef, Suspense, lazy, createPortal, PureComponent, the Children helpers, useSyncExternalStore and the event normalisation layer. A realistic compat-based app therefore carries something in the region of 7 to 10 kB of framework, against react plus react-dom which commonly measure in the low forties of kilobytes gzipped depending on version. That difference matters most where the CPU is slow, not where the network is slow: parse and execute time on a sub-₹15,000 Android device scales with bytes of JavaScript, which is why Indian consumer products with heavy mobile-web traffic reach for Preact in the first place.

Proving it is a build-pipeline job, not a one-off measurement. Add rollup-plugin-visualizer to get a treemap of the production chunks, then add size-limit or bundlesize with explicit budgets per entry chunk and wire it into CI so a pull request that adds 40 kB of moment.js fails the check rather than being noticed a quarter later. Track the field metrics too: INP and LCP from the Chrome UX Report or your own web-vitals beacon, segmented by device class, because that is what the size win is meant to buy.

The honest caveat to give in an interview is that framework bytes are usually not the biggest line item. If the app also ships a date library, an icon set imported wholesale, three analytics SDKs and a chat widget, migrating from React to Preact saves 35 kB while the rest of the page wastes 300 kB. Audit the whole bundle before claiming the framework swap as the win.

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [preact(), visualizer({ gzipSize: true, brotliSize: true, filename: 'stats.html' })],
  build: { sourcemap: true, reportCompressedSize: true },
});

// .size-limit.json  (fails CI when a budget is breached)
// [
//   { "name": "entry",  "path": "dist/assets/index-*.js", "limit": "45 kB" },
//   { "name": "vendor", "path": "dist/assets/vendor-*.js", "limit": "60 kB" }
// ]

// package.json
// "scripts": { "size": "size-limit", "analyze": "vite build && open stats.html" }

Key Points

  • Core ~3 kB, hooks ~1 kB, compat a few kB more; react + react-dom is roughly 40+ kB
  • The win is parse and execute time on low-end Android, not just transfer size
  • rollup-plugin-visualizer to see it, size-limit in CI to hold it
  • Track INP and LCP by device class, not just bundle bytes
  • Framework bytes are often dwarfed by third-party scripts; audit everything
Q28

What does islands architecture mean for a Preact component in Astro or Fresh?

IntermediateArchitecture

Answer

In an islands setup the page is server-rendered HTML by default, and only components you explicitly mark as interactive get hydrated, each into its own independent root. In Astro that is the client:* directive on a Preact component imported into an .astro file: client:load hydrates immediately, client:idle waits for requestIdleCallback, client:visible hydrates on IntersectionObserver entry, and client:media hydrates on a media query match. Deno's Fresh does it by convention: anything in the islands/ directory is hydrated, everything else is server-only.

Preact is a natural fit because each island pays its own framework cost, and 3 kB per island is affordable where 40 kB would not be. What breaks, and what the interviewer is checking, is that islands are separate render roots with no shared component tree. A React or Preact context provider in one island does not reach another island, so cross-island shared state has to live outside the component system: signals in a module both islands import, a custom event on window, a store in localStorage, or URL state.

Signals are the canonical answer in Fresh precisely for this reason, since a module-level signal is shared by identity across every island that imports it. Props passed from the server template to an island must be JSON-serialisable, so functions, class instances, Dates and Maps do not survive the boundary. And because non-island components never hydrate, any useEffect in them simply never runs, which surprises people who move a component from an island into shared markup and wonder why the analytics stopped firing.

---
// Astro page: index.astro
import JobFilter from '../components/JobFilter.tsx';
import SaveButton from '../components/SaveButton.tsx';
const jobs = await db.jobs.recent();
---
<main>
  <h1>Jobs in Bengaluru</h1>
  <!-- server-rendered, zero JS -->
  <ul>{jobs.map((j) => <li>{j.title}</li>)}</ul>

  <JobFilter client:visible jobs={jobs} />
  <SaveButton client:idle jobId={jobs[0].id} />
</main>

// shared-state.ts: the only reliable channel BETWEEN islands
import { signal } from '@preact/signals';
export const savedJobIds = signal<number[]>([]);
💡 Pro Tip: Prefer client:visible over client:load for anything below the fold. On a mid-range Android phone that single directive change often moves INP more than any amount of component-level memoisation.
Q29

What is the options object in Preact, and how do devtools and signals plug into it?

AdvancedInternals

Answer

options is a mutable singleton exported from preact that acts as the library's plugin surface. Preact calls into it at fixed points in the render pipeline, and every addon in the ecosystem works by wrapping those functions rather than by any formal plugin API. The documented, stable hooks are options.vnode(vnode), called for every vnode as it is created; options.diffed(vnode), called after a vnode has been diffed and its DOM updated; options.unmount(vnode), called before a vnode is removed; options.event(event), which can transform or replace an event object before handlers run; options.debounceRendering(process), which controls how the rerender queue is scheduled; and options.requestAnimationFrame, used for the deferred useEffect flush.

There is a second tier of underscore-prefixed hooks that are mangled in the published build and appear as options.__b (before diff), options.__r (before a component renders), options.__c (after commit, receiving the commit queue), options.__e (on caught error) and options.__h (on each hook invocation). These are private: preact/debug, preact/devtools and @preact/signals all use them, which is exactly why you should not, since they are the parts most likely to change between major versions. The correct pattern when you do wrap a hook is to capture the previous value and always call through, because three libraries may be layered on the same slot and swallowing the chain silently disables devtools. The legitimate uses in application code are narrow and worth naming in an interview: a render-timing profiler, a vnode-level feature-flag or A/B injector, an event wrapper that adds analytics to every click, and a sanitiser that strips dangerouslySetInnerHTML in a hardened build.

import { options } from 'preact';

// Render-timing instrumentation: always chain to the previous handler
const prevDiff = options.__b;
const prevDiffed = options.diffed;
const timings = new WeakMap();

options.__b = (vnode) => {
  if (typeof vnode.type === 'function') timings.set(vnode, performance.now());
  prevDiff?.(vnode);
};

options.diffed = (vnode) => {
  const start = timings.get(vnode);
  if (start !== undefined) {
    const ms = performance.now() - start;
    if (ms > 16) console.warn('slow render', vnode.type.name, ms.toFixed(1));
    timings.delete(vnode);
  }
  prevDiffed?.(vnode);
};

// Stable hook: transform every event before it reaches a handler
const prevEvent = options.event;
options.event = (e) => { track('ui_event', { type: e.type }); return prevEvent ? prevEvent(e) : e; };
💡 Pro Tip: If you patch options, do it once in a module imported at the very top of your entry file, and guard it behind a dev or diagnostics flag. options patches applied inside a component body run again on every render and stack up handlers.
Q30

Walk through what happens between setState and the DOM update inside Preact's diff.

AdvancedInternals

Answer

The setter marks the component instance dirty and calls enqueueRender, which pushes the instance into a module-level rerenderQueue if it is not already there and schedules a flush through options.debounceRendering or a Promise microtask. When the flush runs, the queue is sorted by vnode depth so ancestors process first, then each still-dirty component is rendered via a diff against its previous vnode. diff() branches on vnode type. For a function or class component it resolves or constructs the instance, runs getDerivedStateFromProps and shouldComponentUpdate where present, calls the render function to get the new children, then hands them to diffChildren.

For a host element it calls diffElementNodes, which creates the DOM node if needed and runs diffProps, comparing old and new props key by key and calling setProperty for each change, which decides property versus attribute and handles the style object and event listener add and remove. diffChildren performs the keyed or index matching described earlier and computes the minimal set of insertBefore and removeChild operations. Two bailouts matter. If shouldComponentUpdate returns false the subtree is skipped entirely.

And if the new vnode's original reference is identical to the old one, meaning the parent passed the exact same vnode object through, Preact copies the previous result and skips the subtree, which is why hoisting a static children prop out of a re-rendering parent is a real optimisation with no memo() required. After the tree is diffed, commitRoot walks the accumulated commit queue calling componentDidMount and componentDidUpdate and running useLayoutEffect callbacks synchronously, then schedules deferred useEffect callbacks. Errors anywhere in this path route through options._catchError to the nearest boundary.

// The _original bailout in practice: same vnode object -> subtree skipped
function Layout({ children }) {
  const [open, setOpen] = useState(false);
  return (
    <div class={open ? 'open' : ''}>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {children /* same vnode reference each render: Preact skips diffing it */}
    </div>
  );
}

// Caller hoists the expensive subtree once
function Page() {
  return (
    <Layout>
      <ExpensiveTree />
    </Layout>
  );
}

// Observe the flush boundary yourself
import { options } from 'preact';
options.debounceRendering = (process) => queueMicrotask(process);

Key Points

  • setState marks _dirty and enqueues; flush is a microtask by default
  • Queue is depth-sorted so parents render before children
  • diffProps + setProperty decide property vs attribute per prop
  • Bailouts: shouldComponentUpdate false, or identical vnode _original reference
  • commitRoot runs lifecycles and layout effects synchronously before paint
Q31

How do you find and fix a memory leak in a long-lived Preact single-page app?

AdvancedMemory

Answer

Preact's vnodes are lighter than React fibers but they are still a graph with back-references: each vnode holds its DOM node, its parent, its component instance, its props and its children, and the component instance holds the hook list with every closure those hooks captured. Anything that keeps one vnode alive keeps its DOM subtree and every closure in it alive. The leaks that actually show up in production are boring and repeatable.

A useEffect that adds a window or document listener, a setInterval, a WebSocket, an IntersectionObserver or a ResizeObserver, and returns no cleanup. A signals effect() created at module scope or inside a handler and never disposed, since effect() returns a dispose function that people discard. A module-level signal holding an array that the app appends to forever, such as a toast log or a fetch cache with no eviction.

A ref that stores a DOM node from a subtree that was later unmounted, keeping detached DOM reachable. And unmounting by clearing innerHTML instead of calling render(null, container), which skips Preact's unmount path so componentWillUnmount and effect cleanups never run. Finding them is a heap snapshot exercise: in Chrome DevTools take a snapshot, exercise the suspected route ten times, force garbage collection, take a second snapshot and use Comparison view sorted by delta.

Filter for Detached to find DOM held by JavaScript, then read the Retainers pane; in a Preact app the retainer chain almost always terminates at a vnode property holding the DOM reference, at a closure in a hook list, or at a listener array on window. Fix by returning a cleanup from every subscribing effect, disposing signal effects, bounding every cache, and adding a route-change assertion in development that listener counts have returned to baseline.

import { useEffect, useRef } from 'preact/hooks';
import { effect } from '@preact/signals-core';

function LiveFeed({ jobId }) {
  const boxRef = useRef(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.in/jobs/${jobId}`);
    const onResize = () => layout(boxRef.current);
    const io = new IntersectionObserver(() => {});
    const timer = setInterval(poll, 5000);

    window.addEventListener('resize', onResize);
    io.observe(boxRef.current);

    // every one of these MUST be undone
    return () => {
      ws.close();
      window.removeEventListener('resize', onResize);
      io.disconnect();
      clearInterval(timer);
    };
  }, [jobId]);

  useEffect(() => {
    const dispose = effect(() => syncToStore()); // effect() returns a disposer
    return dispose;
  }, []);

  return <div ref={boxRef} />;
}

// Correct teardown of a whole root: render(null, container), never innerHTML = ''
Q32

You are migrating a React 19 app to Preact. What genuinely does not work, and how do you de-risk it?

AdvancedMigration

Answer

Start by cataloguing what has no equivalent rather than what looks similar. React Server Components and the whole server-component build pipeline do not exist in Preact, so a Next.js App Router app using them is not a candidate for a compat alias at all. The use() hook, useOptimistic, useActionState and useFormStatus, the React 19 form actions model, are absent.

The concurrency APIs alias to no-op shims, so anything whose responsiveness depended on transitions regresses. StrictMode in compat is a passthrough that does not double-invoke renders and effects, which means bugs React's StrictMode was surfacing for you go quiet rather than getting fixed. Any library built on react-reconciler for a non-DOM target, such as react-three-fiber, react-pdf or Ink, cannot work, because compat replaces react-dom, not the reconciler.

Libraries that import react-dom internals or the internals object directly will break, and the failure is usually a cryptic undefined property read rather than a clear message. react-dom/server must be replaced with preact-render-to-string, which has a different export surface. PropTypes is not bundled. Enzyme is dead in this world; move to Testing Library first.

De-risking is a sequencing problem. Alias in a feature branch and get the type check green before the runtime, because tsconfig paths catch a large share of the incompatibilities statically. Then run the full test suite, then Playwright against the built bundle, since the production Rollup config is where alias mistakes actually bite. Ship behind a build flag to a small traffic slice with web-vitals and error-rate monitoring per cohort, and keep the React build reproducible so rollback is a config change rather than a revert of a hundred commits.

# Step 1: find every hard blocker before writing any code
grep -rnE "react-three-fiber|react-reconciler|react-dom/server|react-dom/client" src/
grep -rnE "useOptimistic|useActionState|useFormStatus|\buse\(" src/
grep -rn "prop-types" src/ package.json
grep -rn "unstable_|__SECRET_INTERNALS" src/ node_modules/*/dist/*.js

# Step 2: alias, then typecheck BEFORE running anything
npx tsc --noEmit

# Step 3: real-browser check against the PRODUCTION build, not the dev server
npm run build && npx playwright test --config=e2e/prod.config.ts

# Step 4: ship behind a flag, watch INP + JS error rate per cohort, keep rollback cheap

Key Points

  • No Server Components, no use(), no useOptimistic or form actions
  • StrictMode is a passthrough, so double-invoke checks silently stop
  • react-reconciler targets (three-fiber, react-pdf, Ink) cannot be supported
  • react-dom/server becomes preact-render-to-string with a different API
  • Typecheck first, then Playwright against the production build, then a flagged rollout
Q33

How would you render a 50,000-row list in Preact without dropping frames?

AdvancedPerformance

Answer

Rendering 50,000 vnodes is off the table regardless of how small the framework is, because the cost is DOM nodes and layout, not framework overhead. The answer is windowing: keep a scroll container with a spacer sized to totalRows times rowHeight, compute the visible index range from scrollTop and container height, add a small overscan buffer, and render only that slice absolutely positioned at index times rowHeight. The Preact-specific refinements are what distinguish a good answer.

First, drive the scroll position with a signal rather than useState, so the scroll handler updates a value that only the windowing computation reads; combined with a computed for the visible range, you avoid re-rendering ancestors on every scroll event. Second, register the scroll listener as passive so it never blocks the compositor, and coalesce updates with requestAnimationFrame so you do at most one recalculation per frame regardless of how many scroll events fire. Third, key rows by a stable item id, never by index, or the reused DOM nodes carry the wrong focus and selection state as the window slides.

Fourth, wrap the row component in memo from preact/compat and pass primitive props, so a window shift that keeps most rows in view does not re-render them. Fifth, add content-visibility: auto with a contain-intrinsic-size hint on each row, which lets the browser skip layout and paint for rows outside the viewport even within the rendered window. For variable-height rows, measure with a ResizeObserver into a cumulative offset array and keep an estimated height for unmeasured rows. Verify with a Chrome performance trace on a mid-range Android profile, not on a laptop, and watch long tasks rather than frames per second.

import { useSignal, useComputed } from '@preact/signals';
import { memo } from 'preact/compat';
import { useRef, useEffect } from 'preact/hooks';

const Row = memo(({ title, top }) => (
  <div class="row" style={{ position: 'absolute', top, height: 36, contentVisibility: 'auto' }}>
    {title}
  </div>
));

export function VirtualList({ items, height = 600, rowHeight = 36, overscan = 6 }) {
  const scrollTop = useSignal(0);
  const ref = useRef(null);

  const window_ = useComputed(() => {
    const start = Math.max(0, Math.floor(scrollTop.value / rowHeight) - overscan);
    const end = Math.min(items.length, start + Math.ceil(height / rowHeight) + overscan * 2);
    return items.slice(start, end).map((it, i) => ({ it, top: (start + i) * rowHeight }));
  });

  useEffect(() => {
    const el = ref.current;
    let queued = false;
    const onScroll = () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => { scrollTop.value = el.scrollTop; queued = false; });
    };
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);

  return (
    <div ref={ref} style={{ height, overflow: 'auto', position: 'relative' }}>
      <div style={{ height: items.length * rowHeight }} />
      {window_.value.map(({ it, top }) => <Row key={it.id} title={it.title} top={top} />)}
    </div>
  );
}
Q34

Preact 11 is on the horizon. How do you keep a 10.x codebase upgradeable?

AdvancedVersioning

Answer

The honest framing first: the 10.x line is what production Preact apps ship on in 2026, and Preact 11 has been in extended alpha with a substantially reworked internal architecture aimed at a faster diff, lower memory per node and cleaner separation between the public vnode and the internal backing structures, alongside the removal of long-deprecated surface. Anyone quoting a firm release date or a firm benchmark number in an interview is guessing, and the right move is to say you would read the release notes before planning anything. What you can do today is make the eventual upgrade a version bump instead of a project.

Stop reading or writing underscore-prefixed properties on vnodes, components and options anywhere in application code, because those are precisely the names a rewrite renames; if you need render instrumentation, isolate it in one module behind a diagnostics flag so there is exactly one file to fix. Drop the deprecated third replaceNode argument to render(). Keep every compat alias and dedupe entry in a single build-config module shared by Vite, Vitest and any SSR bundler, so switching them is a one-file change.

Prefer the public entry points, preact, preact/hooks, preact/compat, preact/jsx-runtime, over reaching into dist paths. Pin exact versions with a lockfile and run renovate or dependabot on a schedule so you take small increments instead of one large jump. Most importantly, build the safety net that makes any framework upgrade cheap: a Playwright suite against the production build, hydration snapshot tests for SSR routes, size budgets in CI, and a canary deployment with web-vitals and error-rate comparison. Then test the alpha in a branch on your real app, since that is also how the ecosystem finds the regressions.

// build/preact-aliases.ts  (single source of truth for vite, vitest and SSR)
export const preactAliases = {
  react: 'preact/compat',
  'react-dom': 'preact/compat',
  'react-dom/test-utils': 'preact/test-utils',
  'react/jsx-runtime': 'preact/jsx-runtime',
};
export const preactDedupe = ['preact', 'preact/hooks', '@preact/signals', '@preact/signals-core'];

// src/diagnostics/instrumentation.ts  (the ONLY file allowed to touch internals)
import { options } from 'preact';
export function installRenderTracing() {
  if (!import.meta.env.DEV) return;
  const prev = options.diffed;
  options.diffed = (vnode) => { measure(vnode); prev?.(vnode); };
}

// Everything else imports only public entry points:
// 'preact' | 'preact/hooks' | 'preact/compat' | 'preact/jsx-runtime'
💡 Pro Tip: Grep your own source for the mangled internals before any major upgrade: `grep -rnE "\\.__[a-zA-Z]\\b" src/` finds the code that will break, and in a healthy codebase it should return nothing outside your diagnostics module.
Q35

How would you architect a large Preact product so the size advantage survives two years of feature work?

AdvancedArchitecture

Answer

The size win is not a property of choosing Preact, it is a property of holding a budget, and most teams lose it within a few quarters. The architecture that holds up has five parts. Enforcement: size-limit budgets per entry chunk wired into CI as a blocking check, plus rollup-plugin-visualizer output attached to the build so a reviewer can see what a pull request added.

Splitting: route-level lazy() with preact-iso so a user landing on a job detail page never downloads the recruiter dashboard, plus dynamic import for genuinely heavy leaves such as a chart library or a rich text editor. Rendering strategy: prerender the SEO-critical routes at build time through @preact/preset-vite's prerender option, or server-render them with renderToStringAsync behind a per-request timeout, so crawlers and first-time mobile users get HTML rather than a spinner, which matters directly for an Indian consumer product where a large share of sessions arrive from search on 4G. State: module-level signals for cross-cutting concerns like the session and the cart, context only for genuinely tree-scoped configuration, and no global store library unless something specifically needs time-travel or middleware, since that is often more bytes than the framework.

Dependency hygiene: a policy that every new dependency is measured before merge, icons imported per-icon rather than as a barrel, date handling via Intl instead of a date library, and third-party scripts loaded lazily and audited quarterly. Around all of that, put observability that reflects the goal: real-user INP, LCP and long-task metrics segmented by device class and network, not lab scores. Finally, keep the React escape hatch documented; if a business-critical dependency turns out to need real React, you want that to be a known, costed decision rather than a discovery during an incident.

Key Points

  • size-limit budgets as a blocking CI check, not an occasional audit
  • Route-level lazy() plus dynamic import for heavy leaves
  • Prerender or SSR the SEO routes; a spinner is not indexable content
  • Module-level signals for cross-cutting state, context only where tree-scoped
  • Measure every new dependency before merge; icons and dates are the usual offenders
  • Real-user INP and LCP segmented by device class, not lab Lighthouse scores
💡 Pro Tip: Write the budget numbers into the repo on day one. A team that has never agreed on a kilobyte limit will always approve one more 30 kB dependency, and the whole reason for picking Preact quietly evaporates.

Companies Hiring Preact

Uber
Microsoft
Groupon
Housing.com
Zomato
Meesho
MakeMyTrip

Salary Insights

Average in India
₹6-18 LPA

Frequently Asked Questions

How much does a Preact developer earn in India?

Roughly ₹6-18 LPA in 2026, which tracks the general frontend band rather than a Preact-specific premium, because almost nobody is hired as a Preact developer. You are hired as a frontend or web performance engineer whose stack happens to include Preact. Freshers and one-to-two-year engineers typically land ₹4-8 LPA, mid-level engineers with three to five years sit around ₹10-16 LPA, and the upper end goes to people who can demonstrate measurable Core Web Vitals improvements on a high-traffic mobile web property. That performance angle is where the real leverage is: an engineer who can walk into an interview with before and after INP and LCP numbers from a real product, and explain the bundle and hydration decisions behind them, negotiates from a much stronger position than one who only knows the API.

How long does it take to learn Preact if I already know React?

About a weekend to be productive and two to three weeks to be genuinely safe in production. The component model, JSX, hooks and context transfer directly, so day one is mostly unlearning. Budget your time on the differences rather than the similarities: aliasing and dedupe in the build config, onInput versus onChange, property versus attribute behaviour, hydration that patches silently instead of warning, and the fact that the concurrency APIs are shims. Then spend real time on @preact/signals, because that is the part with no React equivalent and the part interviewers use to tell whether you have shipped Preact or just read about it. Build one small thing end to end, ideally with SSR or prerendering, since that is where the interesting failures live.

What is expected from a fresher versus an experienced candidate in a Preact interview?

Freshers are assessed on the fundamentals that happen to be framed in Preact: components and props, hooks and their rules, keys and list reconciliation, the event model, and being able to explain why the library is small. Being able to set up a project with npm init preact and explain what preset-vite configures is already above average. Experienced candidates get the production questions: how you diagnosed a duplicate-Preact incident, what you did about a hydration mismatch that never warned, how you held a bundle budget across a year of feature work, whether you can explain what preact/compat costs and what it cannot do, and how you would sequence a React-to-Preact migration with a rollback plan. At senior level the interview is really about performance judgement, and Preact is just the vocabulary it is conducted in.

Is Preact worth learning in 2026 when React 19 exists?

Yes, if you work on the mobile web, and it is a poor use of time if you work on internal dashboards behind a login on office laptops. Preact's reason to exist is delivery and execution cost on constrained devices, which is exactly the constraint for Indian consumer products where a large share of traffic arrives on mid-range Android over patchy 4G. React 19 closed some of the gap on the specific issues Preact solved first, notably custom element property handling, but it did not get smaller. The other reason to learn it is career-shaped: Preact forces you to understand what a renderer actually does, since the whole library is small enough to read in an afternoon. That understanding transfers back to React interviews and makes you better at both.

How does Preact compare with Solid.js and Svelte for a new project?

Preact's differentiator is compatibility: you keep React's mental model, most of the React ecosystem through preact/compat, and your team's existing knowledge, while cutting framework bytes substantially. Solid.js is faster on fine-grained update benchmarks because reactivity is its core rather than an addon, but its JSX only looks like React's and the ecosystem is much smaller. Svelte compiles away most of the runtime and has excellent ergonomics, but it is a different language surface and the hiring pool in India is thinner. Practical guidance: choose Preact when you are migrating an existing React product or hiring from a React talent pool and need the bundle win now; choose Solid or Svelte when you are greenfield, the team is willing to learn a new model, and update performance is the dominant requirement.

Where do I actually find Preact roles in India, and how do I position for them?

Search for the outcome rather than the framework. Roles that use Preact are advertised as frontend engineer, web performance engineer, mobile web engineer or PWA engineer at companies whose product is a high-traffic consumer website: commerce, travel, food delivery, classifieds, media and fintech, plus the India engineering centres of global companies that have publicly invested in fast mobile web such as Uber, Microsoft and Groupon. Position yourself with evidence rather than a skills list. A public repository containing a prerendered Preact app, a written breakdown of the bundle and the Core Web Vitals before and after, and a short note on one non-obvious problem you solved, for example a hydration mismatch or a duplicate-copy incident, carries far more weight in a screening call than adding Preact to a list of fifteen frameworks.

Introduction

Preact is a roughly 3 kB gzipped view library that keeps the component model, JSX, hooks and context you already know, but implements them on top of real DOM events and a small synchronous diff instead of a fiber scheduler and a synthetic event system. The core package ships only createElement, Component, render, hydrate, Fragment and createContext. Hooks live in preact/hooks, and the React API surface (memo, forwardRef, Suspense, lazy, createPortal, PureComponent) lives in preact/compat, which you alias react and react-dom onto. That split is the whole design: you pay for what you import, and a typical Preact bundle lands well under 10 kB.

Interviewers rarely ask you to recite Preact trivia. They ask what changes when you swap React out on a real product: why onChange stopped firing on every keystroke, why hydration silently patched a mismatch instead of warning, why a third-party React library exploded because it reached into react-dom internals, why hooks threw after a bad pnpm install pulled two copies of Preact. Signals questions are now standard too, because @preact/signals lets a value update the DOM without re-rendering the component at all. Performance rounds go straight to bundle budgets, prerendering and islands, since that is why teams choose Preact in the first place.

This guide covers 35 Preact interview questions asked in 2026, ordered from fundamentals to production architecture. Each answer explains the actual runtime behaviour, the failure mode you will hit in production, and what the interviewer is really testing, with a code example wherever the API detail matters. Work through the basic section to lock down aliasing, events and rendering, then push into signals, SSR and hydration, the options hook system, memory behaviour in long-lived SPAs, and how to plan a React-to-Preact migration that does not strand you on features Preact will never ship.

Ready to practice Preact interviews?

Don't just read, practice these Preact questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview