import { type FunctionComponent, memo, type PropsWithChildren, useEffect, useRef, useState } from "react"; /** * Minimal Controller for managing component state and lifecycle. */ export abstract class Controller< TState extends Record = {}, TProps extends Record = {}, > { state: TState = {} as TState; props: TProps = {} as TProps; #initiated = false; #destroyed = false; #setState: (state: Partial) => void; declare readonly $state: TState; declare readonly $props: TProps; constructor(setState: (state: Partial) => void) { this.#setState = setState; } /** * Factory method to create a new controller instance. */ static make( this: TController, setState: any, setLoading: any, setError: any, ): InstanceType { // biome-ignore lint/complexity/noThisInStatic: should return new instance of child class return new (this as any)(setState, setLoading, setError) as InstanceType; } /* |-------------------------------------------------------------------------------- | Lifecycle |-------------------------------------------------------------------------------- */ async $init(props: TProps): Promise { if (this.#destroyed === true) { return; } this.props = props; if (this.onInit === undefined) { return; } const state = await this.onInit(); if (this.#destroyed === false) { this.setState(state); } this.#initiated = true; } async $resolve(props: TProps): Promise { if (this.#initiated === false || this.#destroyed === true) { return; } this.props = props; if (this.onResolve === undefined) { return; } const state: Partial = await this.onResolve(); if (this.#destroyed === false) { this.setState({ ...this.state, ...state }); } } async $destroy(): Promise { this.#destroyed = true; await this.onDestroy(); } /* |-------------------------------------------------------------------------------- | Lifecycle Hooks |-------------------------------------------------------------------------------- */ /** * Called every time props change (including first mount). */ async onInit(): Promise> { return {}; } /** * Called every time props change (including first mount). */ async onResolve(): Promise> { return {}; } /** * Called when the controller is destroyed. */ async onDestroy(): Promise {} /* |-------------------------------------------------------------------------------- | State Management |-------------------------------------------------------------------------------- */ /** * Updates the controller state. */ setState(state: Partial): void; setState(key: K, value: TState[K]): void; setState(...args: [K | Partial, TState[K]?]): void { if (this.#destroyed) { return; } const [target, value] = args; if (typeof target === "string") { this.state = { ...this.state, [target]: value }; } else { this.state = { ...this.state, ...(target as Partial) }; } this.#setState(this.state); } /* |-------------------------------------------------------------------------------- | Actions |-------------------------------------------------------------------------------- */ /** * Returns all public methods as bound actions. */ toActions(): ControllerActions { const actions: any = {}; const prototype = Object.getPrototypeOf(this); for (const name of Object.getOwnPropertyNames(prototype)) { if (this.#isAction(name)) { const method = (this as any)[name]; if (typeof method === "function") { actions[name] = method.bind(this); } } } return actions; } #isAction(name: string): boolean { return name !== "constructor" && !name.startsWith("$") && !name.startsWith("_") && !name.startsWith("#"); } } /* |-------------------------------------------------------------------------------- | Component |-------------------------------------------------------------------------------- */ export function makeControllerComponent Controller>( ControllerClass: TController, Component: FunctionComponent< PropsWithChildren< InstanceType["$props"] & InstanceType["$state"] & ControllerActions> > >, LoadingComponent?: FunctionComponent, ErrorComponent?: FunctionComponent>, ) { const container: FunctionComponent = (props: any) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [state, setState] = useState(); const controller = useRef | null>(null); const actions = useRef> | null>(null); // biome-ignore lint/correctness/useExhaustiveDependencies: should only execute once useEffect(() => { const instance = (ControllerClass as any).make(setState); controller.current = instance; actions.current = instance.toActions(); instance .$init(props || {}) .then(() => { setLoading(false); }) .catch((error: unknown) => { setError(error); setLoading(false); }); return () => { instance.$destroy(); }; }, []); useEffect(() => { controller.current?.$resolve(props || {}).catch((error) => { setError(error); }); }, [props]); if (loading === true || state === undefined) { return LoadingComponent ? : null; } if (error !== undefined) { return ErrorComponent ? : null; } return ; }; container.displayName = `${ControllerClass.name}Component`; return memo(container); } /* |-------------------------------------------------------------------------------- | Types |-------------------------------------------------------------------------------- */ type ControllerActions = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K extends `$${string}` | `_${string}` | `#${string}` | "constructor" ? never : T[K] : never; };