Tagix: My Experimental State Management Library
Every time I write a state handler, I find myself asking the same question: What happens if I miss a case?
State management is about transitions. Your application moves from one state to another, and your code needs to handle every possibility. But humans miss things. We forget edge cases. We assume certain states won't happen.
What if TypeScript could help? What if, instead of relying on tests to catch missed handlers, we could make TypeScript do that work for us?
That's the question that started Tagix.
The Problem I Was Trying to Solve
I wanted to write state transitions where missing a case would be a type error, not a runtime bug.
Consider this state that can be in one of four conditions:
type UserState =
| { status: "idle" }
| { status: "loading" }
| { status: "authenticated"; user: User }
| { status: "error"; message: string };
In most state management libraries, you'd write a reducer that handles these cases. If you forget one, the application might crash when that unexpected state occurs. Your tests might catch it, or they might not.
But TypeScript's discriminated unions give us something powerful: the ability to narrow types based on runtime values. When you check state.status === 'authenticated', TypeScript knows inside that branch that state.user exists.
I wanted to build a state management library that leveraged this—not as an optional feature, but as the default way of working with state.
My Core Insight
The insight was simple: state should be defined as tagged unions, and state transitions should be functions that return one of those tagged variants.
No action dispatching in the traditional sense. No action types as strings. No middleware pipeline. Just state, transitions, and TypeScript making sure you didn't miss anything.
Here's what that looks like in Tagix:
const UserState = taggedEnum({
Idle: {},
Loading: {},
Authenticated: { user: User },
Error: { message: string },
});
From this definition, Tagix generates:
- A discriminated union type for the state
- Constructor functions for each variant
- Type guards for runtime checks
- A pattern matcher for declarative handling
When you create an action, you're creating a function that takes the current state and returns a new state:
const login = createAction<{ email: string; password: string }, typeof UserState.State>("login")
.withPayload({ email: "", password: "" })
.withState(async (_, { email, password }) => {
const result = await attemptLogin(email, password);
if (result.ok) {
return UserState.Authenticated({ user: result.user });
}
return UserState.Error({ message: result.error });
});
The key point isn't the API—it's that this function must return a valid UserState. TypeScript won't let you return anything else. If you try to return an invalid state, you'll know immediately.
Why Pattern Matching Matters
I spent a lot of time thinking about how developers would read state in their components. Most libraries use selectors or subscriptions. These work, but they don't leverage TypeScript's type narrowing.
In Tagix, you use the $match function:
function displayUser(state: UserState): string {
return UserState.$match(state, {
Idle: () => "Waiting to start...",
Loading: () => "Loading your account...",
Authenticated: ({ user }) => `Welcome back, ${user.email}!`,
Error: ({ message }) => `Something went wrong: ${message}`,
});
}
This function handles every state variant. Add a new variant to UserState, and TypeScript forces you to add a handler here. No default case to forget, no implicit fallthrough.
The benefit is confidence. When your code type-checks, you've handled every possible state.
The Core Abstractions
Tagix is built around three fundamental abstractions that work together. Understanding these will help you use the library effectively.
Tagged Enum
The taggedEnum function creates a state constructor and a set of type utilities:
const UserState = taggedEnum({
Idle: {},
Loading: {},
Authenticated: { user: User },
Error: { message: string },
});
This single line does a lot of work. First, it creates a type UserStateType that is the union of all variants. Second, it creates constructor functions for each variant (UserState.Idle(), UserState.Loading(), etc.). Third, it creates a $match function for pattern matching. Fourth, it creates $is type guards for each variant.
The constructors are particularly useful because they create both the runtime value and the type simultaneously:
const authenticatedState = UserState.Authenticated({ user: { id: "1", name: "Alice" } });
// authenticatedState has type: { _tag: 'Authenticated'; user: User }
Actions
Actions in Tagix are different from what you might have used elsewhere. An action is a function that takes the current state and returns a new state:
const login = createAction<{ email: string; password: string }, typeof UserState.State>(
"user/login"
)
.withPayload({ email: "", password: "" })
.withState(async (_, { email, password }) => {
const result = await attemptLogin(email, password);
if (result.ok) {
return UserState.Authenticated({ user: result.user });
}
return UserState.Error({ message: result.error });
});
The key insight is that actions return state directly. There's no dispatching to a reducer, no action types to match, no switch statements. Your action is your state transition.
The .withPayload() and .withState() calls set up type inference. The .withPayload<PayloadType>() tells Tagix what shape the payload has. The .withState() tells Tagix what state this action works with.
The Store
The store ties everything together:
const userStore = createStore(UserState.Idle({}), UserState);
userStore.register(login);
userStore.dispatch(login, { email: "test@example.com", password: "secret" });
The store holds the current state and handles action dispatching. When you register an action, the store knows which actions are available. When you dispatch an action, the store calls it with the current state.
You can subscribe to state changes:
userStore.subscribe((state) => {
console.log("State changed:", state);
});
Async Actions
Real-world applications have async operations. Tagix handles this naturally because action functions can be async:
const loadUserData = createAction<{ userId: string }, typeof UserState.State>("user/load-data")
.withPayload({ userId: "" })
.withState(async (_, { userId }) => {
try {
const data = await fetchUserData(userId);
return UserState.Ready({ data, loadedAt: new Date() });
} catch (error) {
return UserState.Error({
message: error instanceof Error ? error.message : "Unknown error",
userId,
});
}
});
The store handles awaiting automatically. While the promise resolves, the state is in whatever variant you returned first.
Designing State Machines
One of the benefits of Tagix is that it encourages thinking about your state as a proper state machine. Every state is a variant, every transition is explicit.
Here's how I'd design an authentication flow:
const AuthState = taggedEnum({
Unauthenticated: {},
Authenticating: { email: string },
Authenticated: {
user: User;
sessionExpiresAt: Date;
permissions: string[]
},
SessionExpired: { user: User },
LoggingOut: {},
});
const login = createAction<{ email: string; password: string }, typeof AuthState.State>(
'auth/login'
).withPayload({ email: "", password: "" })
.withState(async (_, { email, password }) => {
const result = await verifyCredentials(email, password);
if (result.ok) {
const session = await createSession(result.userId);
return AuthState.Authenticated({
user: result.user,
sessionExpiresAt: session.expiresAt,
permissions: result.permissions,
});
}
return AuthState.Unauthenticated();
});
const logout = createAction<void, typeof AuthState.State>(
'auth/logout'
).withPayload()
.withState(() => {
clearSession();
return AuthState.Unauthenticated();
});
const checkSession = createAction<void, typeof AuthState.State>(
'auth/check-session'
).withPayload()
.withState(async (currentState) => {
if (AuthState.$is('Authenticated')(currentState)) {
const isValid = await verifySession(currentState.sessionExpiresAt);
if (!isValid) {
return AuthState.SessionExpired({ user: currentState.user });
}
}
return currentState;
});
Notice how every action has a clear entry and exit point. You can look at any action and understand what states it can produce. You can look at any state and understand how you got there.
Error Handling Patterns
Tagix works well with error boundaries and recovery flows:
const DataState = taggedEnum({
Initial: {},
Loading: {},
Ready: { data: Data },
Error: { error: ErrorInfo; retryCount: number },
});
const loadData = createAction<void, typeof DataState.State>(
'data/load'
).withPayload()
.withState(async (currentState) => {
if (DataState.$is('Error')(currentState) && currentState.retryCount >= 3) {
return currentState;
}
try {
const data = await fetchData();
return DataState.Ready({ data });
} catch (error) {
const retryCount = DataState.$is('Error')(currentState)
? currentState.retryCount + 1
: 1;
return DataState.Error({
error: {
message: error instanceof Error ? error.message : 'Unknown error',
code: 'FETCH_ERROR',
},
retryCount,
});
}
});
const resetError = createAction<void, typeof DataState.State>(
'data/reset-error'
).withPayload()
.withState(() => {
return DataState.Initial();
});
The error state includes a retryCount, allowing you to implement backoff strategies or give up after a certain number of attempts.
Combining State Machines
Real applications often have multiple state machines that interact. Here are approaches that work:
Parallel State Machines
If you have independent state machines (like a user session and a modal dialog), you can combine them:
const UserState = taggedEnum({
/* ... */
});
const ModalState = taggedEnum({
/* ... */
});
type AppState = {
user: typeof UserState.State;
modal: typeof ModalState.State;
};
const appStore = createStore({
user: UserState.Idle({}),
modal: ModalState.Closed({}),
});
const openSettings = createAction<void, typeof AppState.State>("app/open-settings")
.withPayload()
.withState((currentState) => ({
...currentState,
modal: ModalState.Settings({}),
}));
Nested State Machines
For dependent state machines, you can nest one inside another:
const CheckoutState = taggedEnum({
Cart: { items: CartItem[] },
Shipping: { shippingInfo: ShippingInfo; shippingMethod: string },
Payment: { shipping: ShippingInfo; paymentMethod: PaymentMethod },
Complete: { orderId: string },
});
const AppState = taggedEnum({
Shopping: { checkout: typeof CheckoutState.State },
Browsing: { cart: CartItem[] },
});
function checkoutWithCart(cart: CartItem[]): AppState {
return AppState.Shopping({
checkout: CheckoutState.Cart({ items: cart }),
});
}
Performance Considerations
Tagix is designed to be minimal in runtime overhead. Here are some patterns for keeping things fast:
Selective Subscriptions
You don't always need to subscribe to the entire state:
store.subscribe((state) => {
if (UserState.$is("Authenticated")(state)) {
updateUI(state.user);
}
});
Memoization with Selectors
For derived data, use memoization:
import { memoize } from "tagix";
const selectUserDisplayName = memoize((state: UserState): string => {
return UserState.$match(state, {
Authenticated: ({ user }) => user.displayName,
Loading: () => "Loading...",
Idle: () => "Not logged in",
Error: () => "Error",
});
});
const displayName = selectUserDisplayName(store.stateValue);
Common Patterns
Here are patterns that emerge naturally when using Tagix:
Initialization Actions
Many applications have an initialization phase:
const AppState = taggedEnum({
Initializing: { progress: number },
Ready: { user: User; config: AppConfig },
Failed: { error: string },
});
const initializeApp = createAction<void, typeof AppState.State>(
'app/initialize'
).withPayload()
.withState(async () => {
const progress = { current: 0, total: 3 };
const config = await loadConfig();
progress.current++;
const user = await loadUser();
progress.current++;
const preferences = await loadPreferences();
progress.current++;
return AppState.Ready({ user, config: { ...config, preferences } });
});
Optimistic Updates
For better user experience, you can update the UI optimistically:
const toggleTodo = createAction<{ id: string }, typeof TodoState.State>("todos/toggle")
.withPayload({ id: "" })
.withState((currentState, { id }) => {
if (TodoState.$is("Ready")(currentState)) {
const updated = currentState.items.map((item) =>
item.id === id ? { ...item, completed: !item.completed } : item
);
return TodoState.Ready({ items: updated });
}
return currentState;
});
Undo/Redo
Implementing undo/redo is straightforward with Tagix:
const EditableState = taggedEnum({
Current: { document: Document },
UndoStack: { current: Document; history: Document[] },
RedoStack: { current: Document; history: Document[] },
});
const undo = createAction<void, typeof EditableState.State>(
'editor/undo'
).withPayload()
.withState((currentState) => {
if (EditableState.$is('Current')(currentState)) {
return currentState;
}
if (EditableState.$is('UndoStack')(currentState)) {
const [previous, ...remaining] = currentState.history;
return EditableState.Current({ document: previous });
}
return currentState;
});
Testing Strategies
Tagix's explicit state transitions make testing straightforward:
describe("login action", () => {
it("transitions to Authenticated on success", async () => {
const mockState = UserState.Idle({});
const mockPayload = { email: "test@example.com", password: "secret" };
const result = await loginAction(mockState, mockPayload, {
attemptLogin: async () => ({ ok: true, user: { id: "1", name: "Test" } }),
});
expect(UserState.$is("Authenticated")(result)).toBe(true);
if (UserState.$is("Authenticated")(result)) {
expect(result.user.name).toBe("Test");
}
});
it("transitions to Error on failure", async () => {
const mockState = UserState.Idle({});
const mockPayload = { email: "test@example.com", password: "wrong" };
const result = await loginAction(mockState, mockPayload, {
attemptLogin: async () => ({ ok: false, error: "Invalid credentials" }),
});
expect(UserState.$is("Error")(result)).toBe(true);
});
});
The explicit transitions mean you can test each possible path through your state machine.
A Honest Note About Scope
Tagix isn't trying to compete with established libraries. Redux has a decade of ecosystem, DevTools, and community patterns. Zustand is minimal and works incredibly well for most use cases. MobX has influenced how we think about reactivity.
Tagix is different—an experiment in taking TypeScript's type system seriously for state management. It makes certain trade-offs that larger libraries wouldn't:
- Your state must be a discriminated union
- You need to think about state transitions upfront
- The ecosystem is newer and smaller
If you're already happy with your current state management solution, Tagix isn't trying to convince you to switch. But if you've wished for stronger type guarantees, or experienced runtime bugs from missing state handlers, Tagix might be worth exploring.
The Design Decisions
Building Tagix forced me to make choices. Here are the important ones:
Decision 1: No Action Types as Strings
Other libraries use strings like 'USER_LOGIN_REQUEST' to identify actions. In Tagix, actions are functions. When you dispatch login, you're dispatching the login function. No string comparison happens.
You can't accidentally dispatch the wrong action type. No typos in action constants. The function is the action.
Decision 2: Actions Return State Directly
In Redux, actions are dispatched and reducers handle them. In Tagix, actions are the state transition logic. The action function receives the current state and returns the new state.
The flow is obvious. No middleware intercepting actions, no mysterious state updates in reducers you didn't write. Your action is your state transition.
Decision 3: Exhaustive Pattern Matching
Every time you use $match, you're forced to handle every variant. No partial handlers, no forgotten cases.
This isn't always convenient—there are times when you genuinely don't care about certain states—but it's consistent. You always know what states you're handling.
What I Learned
Building Tagix taught me several things about state management and type systems:
First, design matters more than implementation. The way you model your state affects everything downstream. Tagged unions encourage thinking about state as distinct modes rather than collections of properties.
Second, type systems can do more than we typically ask. We're used to TypeScript catching type mismatches. But it can also enforce behavioral invariants—transitions, exhaustiveness, state machine validity.
Third, simplicity beats features. Tagix has fewer features than Redux or other libraries. But the features it has are well-integrated. Every abstraction builds on the previous ones.
Fourth, documentation matters. Building a library is only half the work. Explaining how to use it, showing patterns, providing examples—that's where the real challenge lies.
Where Tagix Fits
Tagix is an experiment. It works best when:
- Your application has clear state modes (loading/success/error, authenticated/unauthenticated)
- You want TypeScript to catch bugs at the type-checking stage
- You're willing to think about state transitions explicitly
- Your team appreciates type safety
Tagix isn't right for every project. If you have simple state (toggle a boolean, store a single object), a simpler solution might be better. If you're working with legacy codebases, migrating to a tagged union model might be costly.
But for new applications where state complexity is high and correctness matters, Tagix offers a different way of thinking about state management.
The Road Ahead
Tagix is still young. Here's what's coming:
- Better React integration with hooks
- A DevTools extension for debugging
- Middleware plugins for common patterns
- More examples and documentation
But the core idea won't change. State as tagged unions. Actions as transition functions. TypeScript as your partner in correctness.
A Note on Effuse
The Tagix documentation website itself is built with Effuse—another library I created. Effuse is a component-based UI library with fine-grained reactivity, no virtual DOM, and the same focus on type inference that Tagix has.
I built Effuse because I wanted the Tagix documentation to demonstrate the library in action. Using two experimental libraries together has been instructive—both in showing what's possible and in identifying pain points that needed addressing.
If you're interested in component-based UI development with a different philosophy than React or Vue, check out Effuse as well.
Trying Tagix
The documentation is at tagix-docs.vercel.app. The source code is on GitHub at github.com/chrismichaelps/tagix.
There's a getting started guide, API documentation, and examples. I hope you'll give it a try and let me know what you think.
State management is hard. Tagix won't make it easy, but it might make it safer. And that's worth something.
Links:
- Source code: https://github.com/chrismichaelps/tagix
- Documentation: https://tagix-docs.vercel.app/
- Effuse (library used to build the docs): https://github.com/chrismichaelps/effuse
Top comments (0)