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 (1)

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

How does dependency extraction handle a helper called from an effect when that helper reads a Reactive<T> indirectly?