Skip to content

gabeklein/expressive-state

Repository files navigation

Expressive Logo

Expressive State

Class-based reactive state management for modern UI frameworks

NPM

Define classes to power UI by extending State.
Built-in hooks manage renders automatically for any data.
When properties change, your components update too.


Table of Contents


Overview

Expressive State is a reactive state management library built around classes. It provides framework-agnostic reactive primitives with dedicated adapters for React, Preact, and Solid.

Why Expressive?

  • Class-based: Leverage TypeScript's class features for type-safe, self-documenting state
  • Reactive: Automatic fine-grained subscriptions - components only re-render when accessed properties change
  • Portable: State logic lives in classes, not components - easy to test and reuse
  • Context-aware: Built-in dependency injection via hierarchical contexts
  • Framework-agnostic: Core primitives work anywhere; framework adapters provide integration

Installation

npm install @expressive/react
import State from '@expressive/react';

For other frameworks, use @expressive/preact, @expressive/solid, or the core @expressive/state package.


Quick Start

  1. Create a State class with your values and methods
  2. Use State.use() in a component to create an instance
  3. Destructure properties you need - this automatically subscribes to them
  4. Update via assignment - components re-render automatically

Simply define a custom State -

import State from '@expressive/react';

class Counter extends State {
  count = 0;

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }
}

- and use it in a component!

function CounterWidget() {
  const { count, increment, decrement } = Counter.use();

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

It's that simple! Try it in a sandbox →


Key Features

Simple Updates

State management is portable because values are held in an object. Updates may originate from anywhere with a reference to the model.

class Control extends State {
  count = 0;
  message = 'Hello';

  increment() {
    this.count++;
  }
}
function MyComponent() {
  const { is: control, count, message, increment } = Control.use();

  return (
    <div>
      <p onClick={() => (control.count += 10)}>Count: {count}</p>
      <p onClick={() => (control.message = 'Updated!')}>Message: {message}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

View in CodeSandbox

The reserved property is loops back to the instance, helpful to update values after destructuring.


Async Operations

With no additional libraries, Expressive makes async operations simple. Just use regular async functions!

class Control extends State {
  agent = 'Bond';
  remaining = 30;
  dead?: boolean = undefined;

  // Called when State is created.
  new() {
    const timer = setInterval(() => {
      const remains = this.remaining--;

      if (remains === 0) {
        this.dead = Math.random() > 0.5;
        clearInterval(timer);
      }
    }, 1000);

    // Cleanup runs when State is destroyed
    return () => clearInterval(timer);
  }

  async getNewAgent() {
    const res = await fetch('https://randomuser.me/api?nat=gb&results=1');
    const data = await res.json();
    const recruit = data.results[0];

    this.agent = recruit.name.last;
  }
}
function Situation() {
  const { agent, dead, remaining, getNewAgent } = Control.use();

  if (dead === true) return <h2>Mission failed!</h2>;
  if (dead === false) return <h2>Mission successful!</h2>;

  return (
    <div>
      <p>
        Agent {agent}, you have {remaining} seconds!
      </p>
      <button onClick={getNewAgent}>Get another agent</button>
    </div>
  );
}

View in CodeSandbox


Shared State via Context

React adapter exports Provider and adds static get() to all State. Your classes act as their own key and confer full types.

import State, { Provider } from '@expressive/react';

class SharedData extends State {
  foo = 0;
  bar = 0;
}

function App() {
  return (
    <Provider for={SharedData}>
      <Foo />
      <Bar />
    </Provider>
  );
}

function Foo() {
  const { foo, is } = SharedData.get();
  return (
    <div>
      <p>Foo: {foo}</p>
      <button onClick={() => is.bar++}>Increment Bar</button>
    </div>
  );
}

function Bar() {
  const { bar, is } = SharedData.get();
  return (
    <div>
      <p>Bar: {bar}</p>
      <button onClick={() => is.foo++}>Increment Foo</button>
    </div>
  );
}

Use Consumer for render props:

import { Consumer } from '@expressive/react';

function UserDisplay() {
  return (
    <Consumer for={User}>
      {(user) => (
        <div>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
        </div>
      )}
    </Consumer>
  );
}

View in CodeSandbox


Composable States

States can contain other states, for clean composition.

class Address extends State {
  street = '';
  city = '';
  zip = '';
}

class UserProfile extends State {
  name = 'John';
  email = '[email protected]';
  address = new Address();

  toggleTheme() {
    this.darkMode = !this.darkMode;
  }
}

function ProfileEditor() {
  const { name, email, address, is } = UserProfile.use();

  return (
    <div>
      <input value={name} onChange={(e) => (is.name = e.target.value)} />
      <input value={email} onChange={(e) => (is.email = e.target.value)} />
      <input
        value={address.street}
        onChange={(e) => (address.is.street = e.target.value)}
      />
    </div>
  );
}

Child states automatically trigger updates in parent components when they change.


Fine-Grained Reactivity

Components only re-render when properties they access change. Nested states enable precise subscriptions.

class UserData extends State {
  profile = new Profile();
  settings = new Settings();
  notifications = 0;
}

class Profile extends State {
  name = 'John';
  email = '[email protected]';
}

class Settings extends State {
  theme: 'light' | 'dark' = 'light';
}
function UserProfile() {
  const {
    profile: { name, is: profile },
    notifications,
    is
  } = UserData.use();

  // Only re-renders when name or notifications change
  return (
    <div>
      <input value={name} onChange={(e) => (profile.name = e.target.value)} />
      <span>{notifications} notifications</span>
      <button onClick={() => is.notifications++}>+1</button>
    </div>
  );
}

function ThemeToggle() {
  const {
    settings: { theme, is: settings }
  } = UserData.use();

  // Only re-renders when theme changes - profile/notifications don't affect this!
  return (
    <button
      onClick={() => (settings.theme = theme === 'light' ? 'dark' : 'light')}>
      {theme} mode
    </button>
  );
}

View in CodeSandbox


Lifecycle Hooks

Important

These methods are not defined on the State prototype. Instead, they are picked up by .new() and .use() when present in your class.

Define a new() method to run logic when a controller is created. Return a cleanup function to run when it's destroyed.

In React Only the use() method is called on every render, perfect for interfacing with external hooks.

import { useNavigate } from 'react-router-dom';

class Timer extends State {
  elapsed = 0;
  interval: any;

  // Called once when the controller is created
  new() {
    this.interval = setInterval(() => {
      this.elapsed++;
    }, 1000);

    // Cleanup function runs when component unmounts
    return () => clearInterval(this.interval);
  }

  // Called every render - use this to interface with external hooks
  use() {
    const navigate = useNavigate();

    if (this.elapsed >= 10) {
      navigate('/completed');
    }
  }
}
function RedirectTimer() {
  const { elapsed } = Timer.use();

  return <p>Redirecting in {10 - elapsed} seconds...</p>;
}

Reusable Classes

Capture shared behavior as reusable classes and extend them as needed. This makes logic reusable, easy to document and share!

// Define a reusable query pattern
abstract class Query<T> extends State {
  abstract url: string;

  data: T | null = null;
  loading = false;
  error: Error | null = null;

  async fetch() {
    this.loading = true;
    this.error = null;

    try {
      const res = await fetch(this.url);
      this.data = await res.json();
    } catch (e) {
      this.error = e as Error;
    } finally {
      this.loading = false;
    }
  }
}

Extend it for specific use cases

class UserQuery extends Query<User> {
  url = '/api/user';
}

Or use it directly with initialization

function UserProfile({ userId }: { userId: string }) {
  const query = Query.use<User>({
    url: `/api/users/${userId}`
  });

  useEffect(() => {
    query.fetch();
  }, [userId]);

  if (query.loading) return <Spinner />;
  if (query.error) return <ErrorDisplay />;
  return <UserCard user={query.data!} />;
}

Core Concepts


State Class

State is the base class you extend to create reactive state. All properties become reactive - assigning new values automatically triggers updates.

class Session extends State {
  username = '';
  isLoggedIn = false;

  login(name: string) {
    this.username = name;
    this.isLoggedIn = true;
  }
}

Creating instances:

  // In React components, use the hook
function App() {
  const state = Session.use();

  return <div>{state.username}</div>;
}

// Outside React, use State.new()
const state = Session.new();

Methods are auto-bound, so destructuring works safely:

const { login, logout } = Session.use();
<button onClick={logout}>Logout</button>; // ✅ `this` is correct

The is Property

Every State has a non-enumerable is property that references itself. Useful for write access after destructuring:

const { name, is } = Profile.use();
<input value={name} onChange={(e) => (is.name = e.target.value)} />;

Silent reads - access properties through is without subscribing:

state.get((proxy) => {
  console.log(proxy.value); // Subscribes to updates
  console.log(proxy.is.value); // Does NOT subscribe - "silent" read
});

Subscriptions & Effects

The get() method creates reactive effects that automatically re-run when accessed properties change:

const state = MyState.new();

state.get((current) => {
  console.log('Values:', current.foo, current.bar);
  // Only re-runs when foo or bar change (fine-grained)
});

Effect cleanup:

state.get((current) => {
  const interval = setInterval(() => {
    console.log(current.value);
  }, 1000);

  return () => clearInterval(interval);
});

Subscribe to specific properties:

// Watch a single property
state.get('username', (key, thisArg) => {
  console.log('Username changed:', thisArg.username);
});

// Get current value
const username = state.get('username');

// Subscribe to destruction
state.get(null, () => {
  console.log('State destroyed');
});

Lifecycle Methods

new() - Initialization

Runs once when your State is created:

class Timer extends State {
  elapsed = 0;

  new() {
    const interval = setInterval(() => {
      this.elapsed++;
    }, 1000);

    return () => clearInterval(interval);
  }
}

use() - Render-time Hook (React only)

Runs on every render - perfect for integrating external hooks:

import { useNavigate } from 'react-router-dom';

class Navigation extends State {
  use() {
    const navigate = useNavigate();
    if (this.shouldRedirect) {
      navigate('/dashboard');
    }
  }
}

Instructions

Instructions are special property initializers that provide advanced functionality. They run during State construction and define custom getters/setters.


ref - Mutable References

Create ref-compatible properties similar to React's useRef:

import { ref } from '@expressive/react';

class VideoPlayer extends State {
  videoElement = ref<HTMLVideoElement>();

  play() {
    this.videoElement.current?.play();
  }
}

// In component:
<video ref={player.videoElement}>
  <source src="movie.mp4" />
</video>;

With callbacks:

class AutoFocus extends State {
  input = ref<HTMLInputElement>((element) => {
    element.focus();
    return () => element.blur();
  });
}

Ref proxies - create refs for all properties:

class Form extends State {
  name = '';
  email = '';
  fields = ref(this);
}

// Usage:
form.fields.name.current = 'John';
form.fields.email.get((email) => console.log(email));

set - Smart Setters & Computed Values

Provides validation, transformation, computed values, and async initialization.

Validation & Callbacks:

import { set } from '@expressive/react';

class SignupForm extends State {
  username = set('', (newValue, oldValue) => {
    console.log('Changed:', oldValue, '->', newValue);
    // Return false to reject the update
    if (newValue.length < 3) return false;
  });
}

Required Values (Suspense):

class UserProfile extends State {
  // Throws Suspense if accessed before set
  userId = set<string>();

  // Optional - returns undefined
  avatar = set<string>(() => fetchAvatar(), false);
}

Lazy Factories:

class Expensive extends State {
  // Computed on first access
  data = set(() => expensiveComputation());

  // Async - suspends until resolved
  remoteData = set(async () => {
    const res = await fetch('/api/data');
    return res.json();
  });
}

Computed Values:

Reactive properties that update automatically:

class Cart extends State {
  items = [
    { price: 10, quantity: 2 },
    { price: 15, quantity: 1 }
  ];

  // Recomputes when items change
  total = set(this, (state) =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  // Or use method reference
  tax = set(true, this.calculateTax);

  calculateTax() {
    return this.total * 0.08;
  }
}

Access previous value:

class Accumulator extends State {
  input = 0;

  sum = set(this, function (state) {
    const previous = this.sum; // Current value
    return previous + state.input;
  });
}

get - Dependency Injection

Fetch States from context hierarchy:

import { get } from '@expressive/react';

class Dashboard extends State {
  // Required - throws if not found
  userService = get(UserService);

  // Optional - may be undefined
  analytics = get(AnalyticsService, false);

  // Collect all downstream instances
  widgets = get(Widget, true);
}

Lifecycle callbacks:

class Child extends State {
  parent = get(Parent, (parent, thisChild) => {
    console.log('Parent available:', parent);
    return () => console.log('Cleanup');
  });
}

Downstream collection with callbacks:

class ParentList extends State {
  items = get(ListItem, true, (item, thisList) => {
    console.log('Item registered:', item);

    // Return false to prevent registration
    if (!item.isValid) return false;

    // Or return cleanup
    return () => console.log('Item removed:', item);
  });
}

Child State Instances

Nest states by direct assignment — use new State() instead of State.new(). Children are auto-parented and destroyed with parent.

class App extends State {
  theme = new Theme();
  auth = new Auth();
}

React Integration

State.use()

Primary hook for using States in React:

// Create new instance
const counter = Counter.use();

// Pass initial values
const form = Form.use({
  username: 'john',
  email: '[email protected]'
});

// Pass initialization callback
const timer = Timer.use((t) => {
  t.startAt(Date.now());
});

State.get()

Consume States from context:

function ProfileEditor() {
  const user = UserService.get();
  return <input value={user.name} />;
}

Provider & Consumer

Provider - provide States to descendants:

import { Provider } from '@expressive/react';

<Provider for={UserService}>
  <Dashboard />
</Provider>

// Multiple States
<Provider for={{ UserService, ThemeService, AuthService }}>
  <App />
</Provider>

// Provide instances
<Provider for={Theme.new({ mode: 'dark' })}>
  <App />
</Provider>

// With forEach callback
<Provider
  for={Logger}
  forEach={(logger) => {
    logger.log('Mounted');
    return () => logger.log('Unmounted');
  }}>
  <App />
</Provider>

// With Suspense
<Provider
  for={AsyncDataService}
  fallback={<LoadingSpinner />}
  name="Data Boundary">
  <DataDisplay />
</Provider>

Consumer - alternative using render props:

import { Consumer } from '@expressive/react';

<Consumer for={UserService}>
  {(user) => (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )}
</Consumer>;

Advanced Features

Suspense Integration

States integrate seamlessly with React Suspense:

class SuspendingResource extends State {
  // Throws Suspense until resolved
  data = set(async () => {
    const res = await fetch('/api/data');
    return res.json();
  }, true);
}

function DataDisplay() {
  const { data } = SuspendingResource.use();
  return <div>{data.value}</div>;
}

// Wrap with Suspense:
<Suspense fallback={<Loading />}>
  <DataDisplay />
</Suspense>;

Nested State Reactivity

Child updates trigger parent subscriptions:

const user = UserProfile.new();

user.get((current) => {
  // Re-runs when user.address.city changes!
  console.log('City:', current.address.city);
});

user.address.city = 'New York'; // Triggers effect

State Export/Import

Extract and restore state values:

class Form extends State {
  username = '';
  email = '';
  password = '';
}

const form = Form.new();

// Export to plain object
const values = form.get();
// { username: '', email: '', password: '' }

// Save to localStorage
localStorage.setItem('draft', JSON.stringify(values));

// Restore later
const draft = JSON.parse(localStorage.getItem('draft')!);
form.set(draft);

Export handles exotic values:

class Complex extends State {
  normalValue = 'foo';
  refValue = ref<string>();
  computedValue = set(this, (s) => s.normalValue.toUpperCase());
}

const exported = state.get();
// {
//   normalValue: 'foo',
//   refValue: null,        // Ref extracts .current
//   computedValue: 'FOO'   // Computed provides result
// }

Framework Support

Expressive provides first-class support for multiple frameworks:

React

npm install @expressive/react
import State from '@expressive/react';
import { Provider, Consumer } from '@expressive/react';

Preact

npm install @expressive/preact

Solid

npm install @expressive/solid

Framework-Agnostic Core

npm install @expressive/state
import { State, watch, Context } from '@expressive/state';

const state = State.new();
watch(state, (current) => {
  console.log('Value:', current.value);
});

CI/CD Pipeline

This repository uses a staged, two-phase release pipeline:

  1. Feature → staging: Staging validation runs (Build + Coverage).
  2. stagingmain PR: Main gate enforces branch policy, ancestry protection, and test/build validation.
  3. Push to main: release-please opens or updates a release PR.
  4. Merge release PR: Stable publish runs from main committed package versions.
  5. Canary: Manual workflow dispatch (channel=canary) publishes canary versions.

Workflows:


Community & Support

Join our SlackReport IssuesGitHub

Built with ❤️ by the Expressive team

About

Define classes to organize and control state used by React apps

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors