DEV Community

mm1995tk
mm1995tk

Posted on

React state has no type of its own. I gave it one, and it compiles away

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

What the preprocessor wrote for you:

  1. The dependency array [owner, left], from what the effect reads.
  2. 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.
  3. The key, from the first argument of todos.each.

The rules

  • There is no get. You cannot pull the value out of a Reactive<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 with when and match, 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
Enter fullscreen mode Exit fullscreen mode

Low commitment

  • Adopt per file. Files without the sugar are left as they are.
  • See the output. react-preprocessor print src/Todos.tsx prints the plain React a file becomes.
  • Leave any time. react-preprocessor eject src --write rewrites 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:

  1. Does the Reactive<T> / Derived<T> split make the code easier to read for you, or harder?
  2. Which part of the API feels wrong?

Issues and comments are welcome on GitHub. A star helps other people find it.

Top comments (3)

Collapse
 
mm1995tk profile image
mm1995tk •

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

  • A plain helper that calls $(count) in its own body. $ is only allowed inside JSX, reactive.derive, reactive.handler, and reactive.effect.
  • Passing the Reactive<T> itself, as in show(count). A reactive value cannot be used as a plain value.

Forms that compile

  1. Read in the effect, and pass the plain value to the helper:
const show = (n: number) => console.log(n);
reactive.effect(() => {
  show($(count));
});
// ↓
useEffect(() => {
  show(count);
}, [show, count]);
Enter fullscreen mode Exit fullscreen mode
  1. Declare the helper inside the effect:
reactive.effect(() => {
  const show = () => console.log($(count));
  show();
});
// ↓
useEffect(() => {
  const show = () => console.log(count);
  show();
}, [count]);
Enter fullscreen mode Exit fullscreen mode
  1. Call a reactive.handler from the effect. This is the one indirect read:
const show = reactive.handler(() => console.log($(count)));
reactive.effect(() => {
  show();
});
// ↓
const show = () => console.log(count);
useEffect(() => {
  show();
}, [show]);
Enter fullscreen mode Exit fullscreen mode

Here the dependency is show, not count. The effect never sees a stale count, because show is 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, show is memoized on count, so the effect re-runs only when count changes. Without it, the effect over-fires.

So a dependency is never missed. In case 3 the effect can run more often than needed.

Collapse
 
jwhenry3 profile image
Justin Henry •

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

Collapse
 
mm1995tk profile image
mm1995tk •

Thanks for the suggestion.

On the name: I considered computed, but chose derive. The Derived<T> type is not only for computed values. It is also the type of read-only props and of reactive.of(x). Nothing is computed there, so Computed<T> would not fit. I named the function to match the type. It means the same as Svelte's $derived and "derived state" in the React docs.

On useMemo: react-preprocessor does not memoize by itself. derive becomes a plain expression, and the React Compiler, which runs after the preprocessor, memoizes it. For example, after the React Compiler, reactive.derive(() => format($(total))) calls format only when total changes.
I'll keep your point in mind for projects that do not use the React Compiler.