Skip to content

Instantly share code, notes, and snippets.

@fsubal
Last active March 28, 2026 05:07
Show Gist options
  • Select an option

  • Save fsubal/4836b25dd089183e672d38cca4cedf3b to your computer and use it in GitHub Desktop.

Select an option

Save fsubal/4836b25dd089183e672d38cca4cedf3b to your computer and use it in GitHub Desktop.
interface Action<T = unknown> {
type: string;
payload: T;
}
interface StateChangeDetail<A extends Action<unknown>, T> {
action: A;
previousState: T;
nextState: T;
}
type StateChangeCallback<A extends Action<unknown>, T> = (event: CustomEvent<StateChangeDetail<A, T>>) => void;
type StateEvent<T extends State<Action<unknown>, unknown>> = T extends State<infer A, infer S> ? CustomEvent<StateChangeDetail<A, S>> : never;
abstract class State<A extends Action<unknown>, T> {
#currentState: T;
#events = new EventTarget()
#onChangeCallbacks: WeakSet<StateChangeCallback<A, T>> = new WeakSet()
constructor(initialState: T) {
this.#currentState = initialState;
}
subscribe(callback: StateChangeCallback<A, T>) {
this.#onChangeCallbacks.add(callback);
this.#events.addEventListener('change', callback as EventListenerOrEventListenerObject);
}
unsubscribe(callback: StateChangeCallback<A, T>) {
this.#events.removeEventListener('change', callback as EventListenerOrEventListenerObject);
this.#onChangeCallbacks.delete(callback);
}
trigger<K extends A['type']>(type: K, payload: (A & { type: K })['payload']) {
const action = { type, payload } as A & { type: K };
const previousState = this.#currentState;
const nextState = this.handleTrigger(action, this.#currentState);
this.#currentState = nextState;
const event = new CustomEvent<StateChangeDetail<A, T>>('change', { detail: { action, previousState, nextState } });
this.#events.dispatchEvent(event);
}
abstract handleTrigger(action: A, currentState: T): T
}
type UserAction =
| { type: 'USER_LOADED', payload: { id: number } }
| { type: 'USER_SAVED', payload: { name: string } }
type UserState = { id: number | null, name: string | null }
class User extends State<UserAction, UserState> {
handleTrigger(action: UserAction, currentState: UserState): UserState {
switch (action.type) {
case 'USER_LOADED':
return { ...currentState, id: action.payload.id };
case 'USER_SAVED':
return { ...currentState, name: action.payload.name };
default:
return currentState;
}
}
set name(name: string) {
this.trigger('USER_SAVED', { name });
}
async load() {
const response = await fetch('https://api.example.com/users');
const data = await response.json();
this.trigger('USER_LOADED', { id: data.id });
}
}
const userState = new User({ id: null, name: null });
function handleStateChange(event: StateEvent<User>) {
const { action, previousState, nextState } = event.detail;
console.log(action, previousState, nextState);
}
userState.subscribe(handleStateChange);
userState.name = 'John Doe';
userState.trigger('USER_LOADED', { id: 2 });
userState.unsubscribe(handleStateChange);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment