In Vue, Solid, and Angular, state has a type of its own: Ref<T>, Accessor<T>, Signal<T>.
In React, the count from useState is just a number. Nothing in the type tells you:
- that it re-renders the component,
- that it belongs in the dependency array of an effect,
- that a closure may be holding an old copy of it.
Those questions are left to you and to lint rules. I wanted the type to answer them, so I built
react-preprocessor.
Status: experimental. The API is still changing. I am posting this to get feedback.
What it looks like
State is Reactive<T>. What is computed from it is Derived<T>.
import { $, reactive } from "react-preprocessor";
type Todo = { id: number; title: string; done: boolean };
export const Todos = ({ owner }: { owner: string }) => {
const todos = reactive.new<Todo[]>([]); // Reactive<Todo[]>
const left = todos.derive((list) => list.filter((t) => !t.done).length); // Derived<number>
reactive.effect(() => {
document.title = `${owner}: ${$(left)} left`;
});
return (
<ul>
{todos.each(
(todo) => todo.id,
(todo) => (
<li onClick={() => todo.focus("done").update((done) => !done)}>
{todo.derive((t) => t.title)}
</li>
),
)}
</ul>
);
};
At build time, this is rewritten into plain React:
import { useState, useEffect } from "react";
type Todo = { id: number; title: string; done: boolean };
export const Todos = ({ owner }: { owner: string }) => {
const [todos, setTodos] = useState<Todo[]>([]);
const left = todos.filter((t) => !t.done).length;
useEffect(() => {
document.title = `${owner}: ${left} left`;
}, [owner, left]);
return (
<ul>
{todos.map((todo) => (
<li
onClick={() =>
setTodos((prev) =>
prev.map((item) => (item.id === todo.id ? { ...item, done: !item.done } : item)),
)
}
key={todo.id}
>
{todo.title}
</li>
))}
</ul>
);
};
What the preprocessor wrote for you:
-
The dependency array
[owner, left], from what the effect reads. -
The nested immutable update, from
todo.focus("done").update(...). The item is found by key, not by index, so the update hits the right item even if the list was reordered in the same batch. -
The
key, from the first argument oftodos.each.
The rules
- There is no
get. You cannot pull the value out of aReactive<T>. You derive from it, or put it in JSX. - Using a reactive value as a plain value (
`${count}`,if (count)) is a build error with a code frame. - Updates are pure functions:
update(old => next).set(next)is the shortcut for a constant. - Nested state is updated through lenses (
focus,each). Unions are narrowed withwhenandmatch, and the narrowed value is still writable.
The source is valid TypeScript, so your editor, type checker, and formatter work unchanged.
What it is not
-
Not a runtime library.
Reactive<T>exists only at compile time. The output imports only React. - Not a performance tool. Memoization is left to the React Compiler, which runs after the preprocessor:
React + sugar (.tsx)
↓ react-preprocessor
plain React (.tsx)
↓ React Compiler
optimized React
Low commitment
- Adopt per file. Files without the sugar are left as they are.
-
See the output.
react-preprocessor print src/Todos.tsxprints the plain React a file becomes. -
Leave any time.
react-preprocessor eject src --writerewrites the project to the plain React it compiles to.
It works with Vite, Next.js (Turbopack or webpack), and Babel. It also has a Jotai adapter and an oxlint/ESLint rule.
Try it
- In the browser: the website lets you edit the examples and see the plain React they become.
-
In a project:
pnpm add -D react-preprocessor, then follow the setup in the README.
Feedback wanted
I would like to hear:
- Does the
Reactive<T>/Derived<T>split make the code easier to read for you, or harder? - Which part of the API feels wrong?
Issues and comments are welcome on GitHub. A star helps other people find it.
Top comments (3)
Thanks, good question. The analysis does not follow calls into other functions. Instead, the rules limit where a
Reactive<T>can be read.Forms that are build errors
$(count)in its own body.$is only allowed inside JSX,reactive.derive,reactive.handler, andreactive.effect.Reactive<T>itself, as inshow(count). A reactive value cannot be used as a plain value.Forms that compile
reactive.handlerfrom the effect. This is the one indirect read:Here the dependency is
show, notcount. The effect never sees a stalecount, becauseshowis a new function on every render. The cost is that the effect also re-runs on every render. With the React Compiler, which runs after the preprocessor,showis memoized oncount, so the effect re-runs only whencountchanges. Without it, the effect over-fires.So a dependency is never missed. In case 3 the effect can run more often than needed.
My only recommendation is that derive should follow the "computed" term to match signals patterns, as well as have the compiler convert derive/computes to a useMemo. you don't want to filter the list on every render
Thanks for the suggestion.
On the name: I considered
computed, but chosederive. TheDerived<T>type is not only for computed values. It is also the type of read-only props and ofreactive.of(x). Nothing is computed there, soComputed<T>would not fit. I named the function to match the type. It means the same as Svelte's$derivedand "derived state" in the React docs.On useMemo: react-preprocessor does not memoize by itself.
derivebecomes a plain expression, and the React Compiler, which runs after the preprocessor, memoizes it. For example, after the React Compiler,reactive.derive(() => format($(total)))callsformatonly whentotalchanges.I'll keep your point in mind for projects that do not use the React Compiler.