Control Flow

Components for conditional rendering and lists. Cleaner than nested ternaries.

Show

Conditionally render content based on a condition:

import { Show } from 'what-framework';

function UserGreeting({ user }) {
  return (
    <Show when={user} fallback={<p>Please log in</p>}>
      <p>Welcome back, {() => user()?.name}!</p>
    </Show>
  );
}

How Show Works

  • when: the condition to check. Pass a signal by name (when={user}) or a thunk (when={() => user()}). The compiler calls a bare identifier as an accessor when it can tell the name is a signal (a local initialised by signal(), a destructured prop, or an import), so a plain value passed under such a name throws; and it does not call a property access, so when={props.user} tests the function object and stays permanently truthy. A thunk is always safe.
  • fallback: (optional) what to render when the condition is falsy.
  • Children are lazy on both paths. Nothing inside the branch is built or run until when turns truthy, so a plain element child, a component child and a binding like {() => user().name} all survive a first render where user is still null. The one thing to know is the swap: compiled, a binding belonging to a branch that has closed can still re-run when the condition changes again, so an unguarded read of the value that just went away writes a TypeError to the console. The rendered DOM stays correct either way, and {() => user()?.name} keeps the console clean.

With Signals

const isLoggedIn = signal(false);

<Show when={isLoggedIn} fallback={<LoginForm />}>
  <Dashboard />
</Show>

// Toggle login state
isLoggedIn.set(true);  // Now shows Dashboard

Why Show instead of ternary?

Show reads as a gate rather than an expression, and it disposes the branch it is leaving on every swap. With the compiler in the build it also routes the condition through a memo, so only a change in truthiness rebuilds the branch. Without the compiler (plain h() or the automatic JSX runtime) the region re-runs on every write to the condition signal, so keep when narrow if that signal holds an object you replace often.

For

Efficiently render a list of items:

import { For } from 'what-framework';

const items = signal([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Cherry' },
]);

<ul>
  <For each={items}>
    {item => (
      <li>{item.name}</li>
    )}
  </For>
</ul>

How For Works

  • each: the array to iterate. Pass the signal itself, or a thunk. Compiled output calls each(), so a literal array (or each={items()}) throws source is not a function.
  • Children is a function receiving (item, index). index is captured when the row is created and is not refreshed when rows move, so do not use it as a live position counter.
  • A key on the row element does nothing. The key belongs on <For> itself (<For each={items} key={item => item.id}>), and keyed reconciliation only exists when the compiler runs. Note that a keyed compiled <For> hands the render function a signal accessor, so the body becomes {item => <li>{() => item().name}</li>}.
  • Without the compiler (plain h() or the automatic JSX runtime) there is no keyed reconciliation at all: every row is rebuilt on every list change, which loses focus, closes an open <details> and restarts CSS transitions.

Empty State

Give <For> a fallback and it renders that in place of the rows whenever the list is empty or null. Both paths honour it, so the empty state survives a move from a buildless setup to the Vite compiler:

import { For } from 'what-framework';

<ul>
  <For each={items} fallback={<li>No items yet</li>}>
    {item => <li>{item.name}</li>}
  </For>
</ul>

Reactive Updates

const todos = signal([]);

// Add item - list updates automatically
todos.set(t => [...t, { id: Date.now(), text: 'New todo' }]);

// Remove item
todos.set(t => t.filter(item => item.id !== idToRemove));

// Update item
todos.set(t => t.map(item =>
  item.id === id ? { ...item, done: true } : item
));

Switch / Match

Render one of multiple options based on conditions:

import { Switch, Match } from 'what-framework';

const status = signal('loading');

<Switch fallback={<p>Unknown status</p>}>
  <Match when={() => status() === 'loading'}>
    <Spinner />
  </Match>
  <Match when={() => status() === 'error'}>
    <ErrorMessage />
  </Match>
  <Match when={() => status() === 'success'}>
    <Content />
  </Match>
</Switch>

With Data

Match does not hand the matched value to its children: a function written in child position is treated as an ordinary reactive child and called with no arguments. Derive the data separately and read it inside the arm:

import { Switch, Match, signal, computed } from 'what-framework';

const result = signal({ type: 'success', data: [] });
const rows = computed(() => result().data || []);

<Switch>
  <Match when={() => result().type === 'success'}>
    <DataTable items={rows} />
  </Match>
  <Match when={() => result().type === 'error'}>
    <ErrorMessage />
  </Match>
</Switch>

Two build-time rules apply to <Switch>: the compiler reads its arms statically, so write every <Match> out literally (arms produced by {arms.map(...)}, or a spread on <Switch>, are a build error), and each <Match> needs a when prop.

Portal

Render children into a different DOM node:

import { Portal } from 'what-framework';

function Modal({ isOpen, onClose, children }) {
  return (
    <Show when={isOpen}>
      <Portal target={document.body}>
        <div className="modal-overlay" onClick={onClose}>
          <div className="modal" onClick={e => e.stopPropagation()}>
            {children}
          </div>
        </div>
      </Portal>
    </Show>
  );
}

The prop is target, and it takes an element or a CSS selector string (target="#modal-root"). A target that does not resolve is not an error: Portal simply renders nothing.

Common uses for Portal:

  • Modals and dialogs
  • Tooltips and popovers
  • Dropdown menus
  • Notifications/toasts

<Portal> is client-only. It returns null during server rendering, so its content is absent from the server HTML. Hydrating a tree that contains one creates the portal on the client, in its target, and claims nothing at its own position, so the server's nodes on either side of it are left alone.

ErrorBoundary

Catch errors in child components:

import { ErrorBoundary } from 'what-framework';

<ErrorBoundary fallback={({ error, reset }) => (
  <div className="error">
    <p>Something went wrong: {error.message}</p>
    <button onClick={reset}>Try again</button>
  </div>
)}>
  <RiskyComponent />
</ErrorBoundary>

The fallback is called with a single object, { error, reset }, so destructure it. Two positional parameters leave reset undefined and the retry button wired to nothing. There is also an optional onError prop for reporting the error somewhere.

Boundaries work server-side too. A child that throws during a server render puts the fallback in the HTML instead of taking down the whole page response, and the boundary emits its subtree with no wrapper element of its own. Hydration then claims that markup in place rather than rebuilding it: the nodes the server sent are the nodes the browser keeps, no mismatch is logged, and the siblings after the boundary keep their server nodes too. <Suspense> behaves the same way, because a lazy component whose chunk is still loading suspends on the server as well, so the server sends the spinner and hydration claims it. That is the shape of every lazy route on first load.

One case still rebuilds. If the boundary has children before the child that throws, the server sends only the fallback (children render as a unit, so nothing ahead of the thrower reaches the wire either), and on the client those earlier children hydrate first and consume the fallback's markup before the boundary is told an error happened. Nothing is left to reuse, so the boundary discards the region and builds the fallback fresh, logging one hydration mismatch on the way. The page is correct either way; what is lost is the reuse. This cannot be closed from the client, because the server does not mark which arm it rendered, so a boundary whose failing child is its first or only child reuses the server's fallback, and one with content ahead of the failure rebuilds it. Two further cases rebuild deliberately, so a boundary can never claim a node belonging to something else: when the server rendered nothing at that position, and when the node sitting there plainly disagrees with the fallback's root element.

The boundary is fully live after hydration, and reset swaps the children back in. Be precise about what "live" covers, though: ErrorBoundary catches an error thrown while a component is being created, meaning the component function body running. That includes components created after hydration, so flipping a signal that instantiates a failing child does swap in the fallback. It does not catch an error thrown from an event handler or from an effect. A click handler that throws is not caught, the fallback does not appear, and the error goes to the browser's own error handling. This is not a hydration limitation: a client-only render behaves identically. Handlers and effects that can fail have to catch their own errors and drive the UI from state you own, which is the ordinary signal pattern:

const failure = signal(null);

<button onClick={() => {
  try { save(); } catch (e) { failure.set(e.message); }
}}>Save</button>
{() => failure() ? <p className="error">{failure()}</p> : null}

Comparison with Plain JS

Pattern Plain JS What Component
Conditional {cond ? <A/> : <B/>} <Show when={cond} fallback={<B/>}><A/></Show>
List {() => items().map(i => <Li key={i.id}/>)} <For each={items}>{i => <Li/>}</For>
Multi-cond {a ? <A/> : b ? <B/> : <C/>} <Switch><Match>...</Match></Switch>

When to use components vs plain JS

Control flow components read better than nested expressions, and the framework owns the swap rather than your ternary. Use them when the condition depends on signals. For static conditions known at render time, plain JS works fine.

Lists are the exception. A reactive .map() with a key prop is the form the compiler lowers to keyed reconciliation, so it is the default What idiom, and a dev build prints a compiler advisory pointing at it for any file that uses a <For>. Reach for <For> when you want signal-wrapped item accessors.