Control flow
A component runs once, so an if or an early return in its body is evaluated once and never again. To choose something that changes over time, use a component: If, MatchOptional, MatchResult or For. Each mounts what it shows in a scope of its own, and disposes that scope when it switches to something else. None of them adds a wrapper element; each marks its place with a comment node.
import { createBooleanState } from 'synstate';import { constant, If, type JSX } from 'synstate-dom';
const [isLoggedIn$] = createBooleanState(false);
// Both branches are descriptions: only the chosen one is mounted, and// `Dashboard` is not called at all until `isLoggedIn$` becomes true.export const App = (): JSX.Element => ( <If cond={isLoggedIn$} then={<Dashboard />} else={<Login />} />);
const Dashboard = (): JSX.Element => <main>{constant('Dashboard')}</main>;
const Login = (): JSX.Element => <main>{constant('Please log in')}</main>;cond: Bindable<boolean>,then: JSX.Element,else?: JSX.Element. If you omitelse, nothing is shown on that side.- A JSX expression is a description, not DOM, so you can write both branches as props and only the chosen one mounts.
- The branch is rebuilt only when
condflips, which disposes the previous branch’s scope and removes its nodes. With a constantcond,Ifmounts one branch once and subscribes to nothing.
MatchOptional and MatchResult
Section titled “MatchOptional and MatchResult”import { createState, map, type InitializedObservable } from 'synstate';import { constant, MatchOptional, type JSX } from 'synstate-dom';import { Optional } from 'ts-data-forge';
type User = Readonly<{ name: string }>;
const [currentUser$] = createState<Optional<User>>(Optional.none);
// `some` is called once per change from empty to present. While a user stays// signed in, a new value flows through `u$` and the branch is kept.export const Account = (): JSX.Element => ( <MatchOptional value={currentUser$} some={(u$) => <Profile user$={u$} />} none={<p>{constant('Not signed in')}</p>} />);
const Profile = ({ user$,}: Readonly<{ user$: InitializedObservable<User> }>): JSX.Element => ( <p>{user$.pipe(map((u) => `Signed in as ${u.name}`))}</p>);import { createState, map } from 'synstate';import { constant, MatchResult, type JSX } from 'synstate-dom';import { Result } from 'ts-data-forge';
const [parsed$] = createState<Result<number, string>>(Result.ok(0));
export const ParsedValue = (): JSX.Element => ( <MatchResult value={parsed$} ok={(value$) => <output>{value$}</output>} err={(error$) => ( <p role={constant('alert')}> {error$.pipe(map((e) => `Error: ${e}`))} </p> )} />);MatchOptionaltakes aBindable<Optional<T>>andMatchResultaBindable<Result<S, E>>, both types from ts-data-forge.- Each branch function receives an observable of the value narrowed to
T(orS,E). The branch is rebuilt only when the tag changes; while the tag stays the same, new values just flow through that observable. - That observable is derived from
valuerather than written to, so nothing gets written while an update is still propagating.
import { createState, map } from 'synstate';import { For, type JSX } from 'synstate-dom';
type Row = Readonly<{ id: number; label: string }>;
const [rows$] = createState<readonly Row[]>([ { id: 1, label: 'first' }, { id: 2, label: 'second' },]);
// The row function runs once per key. When `rows$` changes, an existing row// receives its new item and index through `row$` and `index$`; reordering moves// its nodes rather than recreating them.export const Table = (): JSX.Element => ( <ul> <For items={rows$} key={(row) => row.id}> {(row$, index$) => ( <li> {index$.pipe(map((i) => `${i + 1}. `))} {row$.pipe(map((row) => row.label))} </li> )} </For> </ul>);items: Bindable<readonly T[]>,key: (item: T, index: number) => unknown, and as children a function(item$, index$) => JSX.Element. As in Solid,index$is created only when the function declares a second parameter. If a row does not need its position, leave the parameter out, and each row has one node fewer to create and update.keyis required, as in React. To match rows by position, say so withkey={(_, i) => i}; there is no separateIndexcomponent.- The row function runs once per key. When the array changes, an existing row receives its new item and index through
item$andindex$(both derived fromitems), and the row function is not called again. A new key gets a new row, and when a key goes away, its row’s scope is disposed and its nodes removed. Forreorders rows with as few moves as the longest increasing subsequence allows. An update that keeps every key in place moves nothing, and one that keeps no key (clearing or replacing the whole list) removes the old rows’ nodes in one go.- If a key appears twice,
Forreports it withconsole.error, as React does. Nothing breaks, but each duplicate is rebuilt on every update.
Portal
Section titled “Portal”import { createBooleanState } from 'synstate';import { constant, If, Portal, type JSX } from 'synstate-dom';
const [isOpen$, { setFalse: close }] = createBooleanState(false);
// The dialog is mounted into `document.body`, out of any ancestor's// `overflow: hidden`, but it belongs to the `If` branch: closing it removes the// dialog too.export const Modal = (): JSX.Element => ( <If cond={isOpen$} then={ <Portal target={document.body}> <div role={constant('dialog')}> <button type={constant('button')} onClick={close}> {constant('Close')} </button> </div> </Portal> } />);Portalmounts its children intotarget(document.bodywhen omitted) instead of where you write it. Use it for a modal, a tooltip or a toast that has to escape an ancestor’soverflow: hiddenorz-index.- The children still belong to the scope where you wrote
Portal, so closing the enclosing branch removes them too.
Calling them as functions
Section titled “Calling them as functions”The control-flow components are plain functions that return a description, so writing If({ cond, then: <A />, else: <B /> }) works the same as the JSX. There is no separate function form such as when(cond$, a, b). People usually ask for one to make the branches lazy, and a JSX expression already is.