Components are states
authored by claude, rubber stamped by Bryan MacLee
TL;DR: <input> is already a state. <Card> should be too. One concept replaces useState, hooks rules, dependency arrays, Zustand, Pinia, Redux, context, and most prop drilling.
> Status: the conceptual frame (state-as-type, primitive <x> = 0 reactive cells, compound state with structural-children, validators on cells, server-side authority via <schema> + protect= + <db> + <channel>) ships in the current compiler. The single scrml example below is a 2026-04 design preview of the user-defined <Card authority="server" table="cards"> "state object" surface — that exact declaration shape may use older draft attribute names. The point of the example is structural (one declaration covers shape + sync + boundary); for up-to-date code see the kickstarter at docs/articles/llm-kickstarter-v2-2026-05-04.md (compound-state, channel, and engine recipes). Correction (2026-06): an earlier version said the compiler generates the optimistic-update and rollback path for authority="server". That auto-persist route was retracted (§52.6.2, 2026-06-14) and never shipped. The compiler generates the read path (initial load, SSR pre-render, the boundary check) and lands assignments locally for an instant response; the persist write is your explicit ?{} server function. The body below reflects the current model.
Every framework I have looked at picks one of useState, ref, createSignal, or signal, and then bolts on a stack of secondary mechanisms to plug the gaps that one primitive cannot fill on its own. Hooks rules. Dependency arrays. A store library. A context API. Prop drilling for everything in between. I have hobbled through React when I had to. I have written enough TypeScript to be annoyed by it. I have spent eighteen months and about twenty compiler attempts circling one question: what would happen if state were a type, and a component were a state?
It turns out the same thing happens that happened with the server boundary. A whole shelf of libraries falls off the shelf.
This is the third of six features the browser-language overview piece promised to unpack. State.
What you write today, and what every line of it costs
A real React app, scoped to one screen. Card list, edit dialog, optimistic updates, server sync. The state surface looks roughly like this.
// useCards.ts
import { create } from "zustand";
interface Card { id: string; title: string; column: "todo" | "doing" | "done"; }
interface CardsStore {
cards: Card[];
isLoading: boolean;
error: string | null;
fetchCards: () => Promise<void>;
addCard: (input: Omit<Card, "id">) => Promise<void>;
}
export const useCardsStore = create<CardsStore>((set) => ({
cards: [],
isLoading: false,
error: null,
fetchCards: async () => {
set({ isLoading: true });
try {
const res = await fetch("/api/cards");
const cards = (await res.json()) as Card[];
set({ cards, isLoading: false });
} catch (e) {
set({ error: String(e), isLoading: false });
}
},
addCard: async (input) => {
const tempId = crypto.randomUUID();
const optimistic = { ...input, id: tempId };
set((s) => ({ cards: [...s.cards, optimistic] }));
// ... POST to /api/cards, reconcile, rollback on failure ...
},
}));
// CardList.tsx
export function CardList() {
const cards = useCardsStore((s) => s.cards);
const isLoading = useCardsStore((s) => s.isLoading);
const fetchCards = useCardsStore((s) => s.fetchCards);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
useEffect(() => { fetchCards(); }, [fetchCards]);
if (isLoading) return <Spinner />;
return (
<ul>
{cards.map((c) => (
<CardRow
key={c.id}
card={c}
isEditing={editingId === c.id}
draftTitle={draftTitle}
setDraftTitle={setDraftTitle}
onStartEdit={() => { setEditingId(c.id); setDraftTitle(c.title); }}
onCancelEdit={() => setEditingId(null)}
/>
))}
</ul>
);
}
Now the costs.
The Card interface lives in TypeScript only. The server schema lives somewhere else. They drift. You catch it in production.
useState has placement rules. Same call order every render. No conditionals. Hooks rules are real lint warnings because the runtime cannot recover from a mistake.
useEffect has a dependency array. Forget an entry, stale closure. Add an extra entry, infinite loop. The IDE helps. It is not a type-system answer.
useCardsStore is a Zustand boilerplate file because cross-component state cannot live in a hook. So you add a library. The library has its own mental model. Now you have two state primitives.
editingId and draftTitle are local to CardList because they are ephemeral. So they leak into every child as props. That is prop drilling. The fix is a context API. That is a third state primitive.
The optimistic update pattern (insert temp ID, POST, reconcile or rollback) is hand-written every time. Every team writes it slightly differently. Every team ships a bug in it eventually.
This is what one screen costs in a framework. The shape of the costs repeats across useState, ref, createSignal, signal. The primitive name changes. The bolt-on stack does not.
The same screen in scrml
< Card authority="server" table="cards">
id: number
title: string
column: Column
</>
< EditState>
editingId: number | not
draftTitle: string
</>
<Card> @cards
<EditState> @ui
<ul>
${@cards.map(c => lift <CardRow card=c
isEditing=${@ui.editingId == c.id}
bind:draftTitle=@ui.draftTitle/>)}
</ul>
server fn renameCard(id: number, title: string(.length > 0 && .length < 200)) {
?{`UPDATE cards SET title = ${title} WHERE id = ${id}`}.run()
}
That is the screen. Both halves.
<Card> is a state type. The fields are typed. authority="server" and table="cards" say where the source of truth lives. The compiler reads the SQLite schema at compile time, generates the initial-load query, pre-renders the list in SSR, and enforces the client/server boundary (§52). The write itself stays one ?{} server function you author, because that is the line where the real decisions live. The Card interface in TypeScript and the schema on disk cannot drift, because there is one declaration.
<EditState> is also a state type. No authority= attribute, so it is client-local by default (§52.3.2). The compiler emits zero sync infrastructure for it.
<Card> and <EditState> are instantiations. They look like HTML element opens because that is what they are at the type level. <input> is a state with a value, a lifecycle, and user interaction. <Card> is too. The grammar treats them the same way.
@cards and @ui are reactive. Reads in markup subscribe. Writes propagate. The compiler tracks the dependency graph at compile time (§31), so the runtime does no diffing to figure out what changed.
bind:draftTitle=@ui.draftTitle is the two-way binding form (§5.2.2). <input bind:value=@x> and <CardRow bind:draftTitle=@ui.draftTitle> are the same mechanism. There is no conceptual gap between "the input element is a state" and "the user-defined component is a state."
title: string(.length > 0 && .length < 200) is an inline type predicate (§53). The constraint is part of the type. A literal that fails the predicate fails the build (E-CONTRACT-001). A runtime value that fails the predicate is rejected at the server boundary before any database write (§53.9.4).
What unifies all of this
One concept. State is a type.
<input> is a built-in state type. <program> is a built-in state type. <channel>, <request>, <timer>, <keyboard>, <mouse> are built-in state types (§36, §37, §38). <Card> is a user-defined state type. @count is a primitive reactive variable, which is the simplest case of the same idea.
Every one of them has the same compile-time guarantees:
1. A typed value. The shape is known to the compiler. 2. Predicate-checked writes. Inline constraints (§53) gate every assignment. 3. Reactive subscribers tracked at compile time. The dependency graph is built before the program runs. 4. Optional authority. State that lives on a server is declared authority="server" and gets its read path generated for free: the initial load, the SSR pre-render, and the boundary check. The write stays your ?{}. 5. Optional state-machine rules. A reactive variable can be bound to a < machine> (§51) so transitions are typed too.
Layer those as you need them. Leave them off where you don't. A counter is one line: @count = 0. A server-synced kanban is the example above. The mechanism is the same.
What this kills
- Hooks rules. A reactive variable is a typed declaration, not a call-order convention. There is no
@varcall sequence to preserve across renders. - Dependency arrays. The compiler builds the dependency graph from the source. A derived value subscribes to what it reads.
- State libraries. Zustand, Pinia, Redux, Jotai, MobX. Cross-component state is a state declared higher up, or a primitive reactive variable at file scope. There is no library to wire in.
- Context APIs. A
@varat the enclosing scope is reachable from any component nested inside it (§6.2). No provider component, no consumer hook. - Most prop drilling. State that does not need to be local does not have to travel through five components to reach where it is used.
- Runtime validators on the wire. Inline predicates (§53) are checked at the boundary because they are part of the type. There is no zod schema sitting next to the TS type.
- Optimistic-update boilerplate. A type with
authority="server"gets the read path generated: the initial load, the SSR pre-render, the boundary check, and the instant-local update on assignment (§52). The persist write stays the developer's one?{}query, and that is on purpose. The write is where server IDs get assigned, where INSERT versus UPDATE gets chosen, and where invariants get enforced, so it is the one place a compiler should not guess for you. I am not asking developers to hand-write the same ten lines of fetch, reconcile, and rollback on every screen. Just the one line that carries a real decision.
That is most of a typical app's package.json state-management section. The reason it can be killed is that one primitive (state-as-type) is doing the work that five different primitives used to share.
What is still real
This is a position piece, not a sales pitch. The point is not that scrml has no concepts. The point is that one concept covers what a stack of libraries used to.
- Some state is genuinely global. Auth session, theme, current user. Declare it at file scope, or in a top-level state block. The mechanism is the same, the placement is the developer's call.
- Some forms are genuinely complex. A multi-step wizard with cross-step validation is not five lines in any language. scrml gives you
< machine>for the transition rules and inline predicates for the value constraints. The shape of the work is smaller. The work is still real. - Some state crosses a network.
authority="server"is the declaration; the read path is generated. The wire is still a wire. The compiler does not pretend latency is zero, and it does not pretend it can write your database for you. It stops asking the developer to hand-write the fetch, the SSR hydration, and the boundary check. The write stays an explicit?{}, where it belongs. - Mutability contracts are deeper than predicates alone. Predicates are the value layer. Lifecycles (
null → number) and machine transitions are the other two. I am writing the dedicated unpacking of all three in the next piece in this series. This article only touches the value layer because that is what the state-as-type frame needs.
The line between "framework plumbing" and "actual app logic" is a lot brighter when one side of it is a typed declaration instead of five libraries negotiating.
The deeper claim
A reactive primitive that is not a type cannot prove anything about itself at compile time. The framework era treated reactivity as a runtime trick (a closure, a proxy, a signal) and built tooling, conventions, and library ecosystems on top to recover the guarantees the type system was supposed to give.
State as a type gets those guarantees back. The compiler knows what every reactive variable is, what shape every component instance has, where the source of truth lives, and which subscribers re-render when a write lands. It knows because the developer wrote it down once, in one declaration, that the compiler reads.
That is the design. Do it right, the first time, even if it takes more time. A little short of perfect is still pretty awesome.
Further reading
- Why programming for the browser needs a different kind of language. The high-altitude six-feature overview that this piece zooms in on.
- The server boundary disappears. The companion zoom-in: why the server fn lets the type system own the wire.
- What npm package do you actually need in scrml?. The package-list-collapses argument, with the state-management category enumerated.
- What scrml's LSP can do that no other LSP can, and why giti follows from the same principle. What vertical integration unlocks for tooling and version control.
- Introducing scrml: a single-file, full-stack reactive web language. The starting-point overview if you haven't seen scrml before.
- Null was a billion-dollar mistake. Falsy was the second.. On
not, presence as a type-system question, and why scrml refuses to inherit JavaScript's truthiness rules. - scrml's Living Compiler. The transformation-registry framing for the compile-time vs. runtime split that makes state-as-type cheap.
- The ORM trap, and why scrml does not need one. Companion piece: SQL as a primitive, not a thing you import.
- Mutability contracts: predicates, lifecycles, machines. The three layers of write-time guarantee the value layer of this piece is one third of.
- CSS without a build step. Native scope, native variables, no styled-components.
- Realtime and workers as syntax.
<channel>and<program>finishing the state-types-as-built-ins story. - scrml on GitHub: github.com/bryanmaclee/scrmlTS. The working compiler, examples, spec, benchmarks.