Skip to content

⛰️ Strongly typed React framework using generators and efficiently updated views alongside the publish-subscribe pattern.

Notifications You must be signed in to change notification settings

Wildhoney/Chizu

Repository files navigation

Checks

Strongly typed React framework using generators and efficiently updated views alongside the publish-subscribe pattern.

View Live Demo →

Contents

  1. Benefits
  2. Getting started

For advanced topics, see the recipes directory.

Benefits

  • Event-driven architecture superset of React.
  • Views only re-render when the model changes.
  • Built-in optimistic updates via Immertation.
  • No stale closures – context.data stays current after await.
  • No need to lift state – siblings communicate via events.
  • Reduces context proliferation – events replace many contexts.
  • No need to memoize callbacks – handlers are stable via useEffectEvent.
  • Clear separation between business logic and markup.
  • Complements Feature Slice Design architecture.
  • Strongly typed dispatches, models, payloads, etc.
  • Built-in request cancellation with AbortController.
  • Granular async state tracking per model field.
  • Declarative lifecycle hooks without useEffect.
  • Centralised error handling via the Error component.
  • React Native compatible – uses eventemitter3 for cross-platform pub/sub.

Getting started

We dispatch the Actions.Name event upon clicking the "Sign in" button and within useNameActions we subscribe to that same event so that when it's triggered it updates the model with the payload – in the React component we render model.name. The With helper binds the action's payload directly to a model property.

import { useActions, Action, With } from "chizu";

type Model = {
  name: string | null;
};

const model: Model = {
  name: null,
};

export class Actions {
  static Name = Action<string>("Name");
}

export default function useNameActions() {
  const actions = useActions<Model, typeof Actions>(model);

  actions.useAction(Actions.Name, With("name"));

  return actions;
}
export default function Profile(): React.ReactElement {
  const [model, actions] = useNameActions();

  return (
    <>
      <p>Hey {model.name}</p>

      <button onClick={() => actions.dispatch(Actions.Name, randomName())}>
        Sign in
      </button>
    </>
  );
}

When you need to do more than just assign the payload – such as making an API request – expand useAction to a full function. It can be synchronous, asynchronous, or even a generator:

actions.useAction(Actions.Name, async (context) => {
  context.actions.produce((draft) => {
    draft.model.name = context.actions.annotate(Op.Update, null);
  });

  const name = await fetch(api.user());

  context.actions.produce((draft) => {
    draft.model.name = name;
  });
});

Notice we're using annotate which you can read more about in the Immertation documentation. Nevertheless once the request is finished we update the model again with the name fetched from the response and update our React component again.

If you need to access external reactive values (like props or useState from parent components) that always reflect the latest value even after await operations, pass a data callback to useActions:

const actions = useActions<Model, typeof Actions, { query: string }>(
  model,
  () => ({ query: props.query }),
);

actions.useAction(Actions.Search, async (context) => {
  await fetch("/search");
  // context.data.query is always the latest value
  console.log(context.data.query);
});

For more details, see the referential equality recipe.

Each action should be responsible for managing its own data – in this case our Profile action handles fetching the user but other components may want to consume it – for that we should use a distributed action:

class DistributedActions {
  static Name = Action<string>("Name", Distribution.Broadcast);
}

class Actions extends DistributedActions {
  static Profile = Action<string>("Profile");
}
actions.useAction(Actions.Profile, async (context) => {
  context.actions.produce((draft) => {
    draft.model.name = context.actions.annotate(Op.Update, null);
  });

  const name = await fetch(api.user());

  context.actions.produce((draft) => {
    draft.model.name = name;
  });

  context.actions.dispatch(Actions.Name, name);
});

Once we have the distributed action if we simply want to read the name when it's updated we can use consume:

export default function Subscriptions(): React.ReactElement {
  return (
    <>
      Manage your subscriptions for your{" "}
      {actions.consume(Actions.Name, (name) => name.value)} account.
    </>
  );
}

However if we want to listen for it and perform another operation in our local component we can do that via useAction:

actions.useAction(Actions.Name, async (context, name) => {
  const friends = await fetch(api.friends(name));

  context.actions.produce((draft) => {
    draft.model.friends = friends;
  });
});

For targeted event delivery, use channeled actions. Define a channel type as the second generic argument and call the action with a channel object – handlers fire when the dispatch channel matches:

class Actions {
  // Second generic arg defines the channel type
  static UserUpdated = Action<User, { UserId: number }>("UserUpdated");
}

// Subscribe to updates for a specific user
actions.useAction(
  Actions.UserUpdated({ UserId: props.userId }),
  (context, user) => {
    // Only fires when dispatched with matching UserId
  },
);

// Subscribe to all admin user updates
actions.useAction(
  Actions.UserUpdated({ Role: Role.Admin }),
  (context, user) => {
    // Fires for {Role: Role.Admin}, {Role: Role.Admin, UserId: 5}, etc.
  },
);

// Dispatch to specific user
actions.dispatch(Actions.UserUpdated({ UserId: user.id }), user);

// Dispatch to all admin handlers
actions.dispatch(Actions.UserUpdated({ Role: Role.Admin }), user);

// Dispatch to plain action - ALL handlers fire (plain + all channeled)
actions.dispatch(Actions.UserUpdated, user);

Channel values support non-nullable primitives: string, number, boolean, or symbol. By convention, use uppercase keys like {UserId: 4} to distinguish channel keys from payload properties.

About

⛰️ Strongly typed React framework using generators and efficiently updated views alongside the publish-subscribe pattern.

Resources

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •