Expressive State

Comparisons

How Expressive State relates to hooks, Zustand, Redux, and MobX

vs React Hooks

Hooks are great for small components. They scale poorly because they organize code around when it runs rather than what it means. As features grow, related state gets smeared across multiple useState calls, effects, callbacks, and dependency arrays.

A typical hook-heavy component

function UserProfile({ userId }) {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setName(data.name);
        setEmail(data.email);
      })
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  const save = useCallback(async () => {
    await fetch(`/api/users/${userId}`, {
      method: 'PUT',
      body: JSON.stringify({ name, email }),
    });
  }, [userId, name, email]);

  // ... render
}

The same feature with Expressive

class UserProfile extends State {
  userId = set<string>();
  name = '';
  email = '';
  loading = false;
  error = set<Error | null>(null);

  data = set(async () => {
    this.loading = true;
    try {
      const res = await fetch(`/api/users/${this.userId}`);
      const data = await res.json();
      this.name = data.name;
      this.email = data.email;
      return data;
    } catch (e) {
      this.error = e as Error;
      throw e;
    } finally {
      this.loading = false;
    }
  });

  async save() {
    await fetch(`/api/users/${this.userId}`, {
      method: 'PUT',
      body: JSON.stringify({ name: this.name, email: this.email }),
    });
  }
}

function UserProfileView({ userId }) {
  const { name, email, loading, save, is } = UserProfile.use({ userId });
  if (loading) return <Spinner />;

  return (
    <form>
      <input value={name} onChange={(e) => (is.name = e.target.value)} />
      <input value={email} onChange={(e) => (is.email = e.target.value)} />
      <button onClick={save}>Save</button>
    </form>
  );
}
React HooksExpressive State
StateMultiple useState callsClass fields
LogicSpread across hooksMethods on the class
ReuseCustom hooks (still React-bound)Plain classes (testable anywhere)
DependenciesManual arrays, error-proneAutomatic tracking
AsyncuseEffect + cleanup flagsRegular async methods
SharingProp drilling or context + hooksProvider + State.get()



vs Zustand

Zustand is a great lightweight store library. The main differences:

// Zustand
const useStore = create((set) => ({
  count: 0,
  name: '',
  increment: () => set((s) => ({ count: s.count + 1 })),
  setName: (name) => set({ name }),
}));

function Counter() {
  const count = useStore((s) => s.count);
  const increment = useStore((s) => s.increment);
  return <button onClick={increment}>{count}</button>;
}
// Expressive
class Counter extends State {
  count = 0;
  name = '';
  increment() { this.count++; }
}

function CounterView() {
  const { count, increment } = Counter.use();
  return <button onClick={increment}>{count}</button>;
}
  • Destructuring is the selector - no need for per-access selector functions or shallow comparisons.
  • Classes are the organizational unit - no factory functions, no spread-based updates.
  • Instances are scoped - a class can back a global store, a per-component instance, or a provider tree. One API.
  • Lifecycle is built in - new() and automatic destruction replace manual subscribe/teardown.



vs Redux (and RTK)

const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    increment: (state) => { state.count++; },
  },
});

const count = useSelector((state) => state.counter.count);
const dispatch = useDispatch();
dispatch(counterSlice.actions.increment());

Redux (even with RTK) introduces actions, reducers, selectors, and a dispatch indirection. Expressive collapses all of these into methods:

class Counter extends State {
  count = 0;
  increment() { this.count++; }
}
Redux / RTKExpressive State
Updatesdispatch(action)Direct assignment
SubscriptionuseSelectorDestructure
OrganizationSlices + storeClasses
TypesInferred from factory shapeDeclared on class
AsyncMiddleware (thunks, sagas)Regular async methods
CompositionStore mergingClass inheritance
DevToolsTime-travel debuggerStandard browser debug

Redux's time-travel devtools are real value - if you rely on them, factor that into your choice.




vs MobX

MobX is the closest relative to Expressive. Both use observable classes with automatic dependency tracking. The differences are mostly about ergonomics and integration:

// MobX
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class Counter {
  count = 0;
  constructor() { makeAutoObservable(this); }
  increment() { this.count++; }
}

const counter = new Counter();

const CounterView = observer(() => (
  <button onClick={() => counter.increment()}>{counter.count}</button>
));
// Expressive
class Counter extends State {
  count = 0;
  increment() { this.count++; }
}

function CounterView() {
  const { count, increment } = Counter.use();
  return <button onClick={increment}>{count}</button>;
}
  • No observer() wrapper - miss one in MobX and you get stale renders. Expressive tracks through use()/get().
  • No makeAutoObservable - extending State is enough.
  • Methods are auto-bound without special configuration.
  • Lifecycle is built in - new() and destruction semantics.
  • Context is built in - Provider, State.get(), get instruction.
  • Suspense is built in - async set() works with React Suspense out of the box.

If you like MobX, you'll feel at home. Expressive is more opinionated: lifecycle, context, and framework integration are part of the library rather than pieces you wire together.

MobXExpressive State
SetupmakeAutoObservable(this)Extend State
Componentsobserver(...) wrapperState.use() / State.get()
BindingManual / configuredAuto-bound
LifecycleDIYBuilt-in new()
ContextExternal libraryBuilt-in Provider + get
SuspenseNot includedBuilt-in async set()

When not to use Expressive

  • Your team has a strict "functional only" code style.
  • Your app is mostly server-rendered with minimal client state.
  • You already have a state solution that works and causes no pain.
  • You rely heavily on Redux devtools' time-travel debugging.

Expressive is a tool for specific kinds of complexity. If you don't have that complexity, you don't need it.

On this page