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 (1)
How does dependency extraction handle a helper called from an effect when that helper reads a
Reactive<T>indirectly?