npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@actdim/dynstruct

v1.4.5

Published

A type-safe component system for large-scale apps: explicit dependencies, message bus communication, and structure-first, declarative design

Readme

@actdim/dynstruct

Build scalable applications with dynamic structured components, explicit wiring, and decoupled message flow. Keep architecture clean and modular.

npm version TypeScript License: Proprietary

Table of Contents

Overview

@actdim/dynstruct is a TypeScript-based component system and architectural framework for building large-scale, modular applications. It provides a structure-first, declarative approach to component design with:

  • Type-safe component model with explicit dependency wiring
  • Decoupled messaging architecture using a message bus for inter-component communication
  • Component lifecycle management with proper initialization and cleanup
  • Automatic reactive state - properties become reactive after component creation
  • Type-safe component events - automatic event handlers for lifecycle and property changes with full IntelliSense
  • Built-in service integration via adapter pattern
  • Parent-child component relationships with message routing

Framework Support

Currently Supported:

  • React

Planned Support:

  • 🚧 SolidJS - In development
  • 🚧 Vue.js - Planned

The architectural core is framework-agnostic, allowing the same component structures and patterns to work across different UI frameworks.

Features

Structure-First Design - Define components with explicit props, actions, children, and message channels

🔒 Full Type Safety - TypeScript generics throughout for compile-time verification

📡 Message Bus Communication - Decoupled component interaction via publish/subscribe pattern

Reactive by Default - Properties automatically trigger UI updates when changed

🔌 Service Adapters - Clean integration of backend services with message bus

🧩 Modular Architecture - Clear component hierarchies with parent-child relationships

🔄 Lifecycle Management - Proper initialization, layout, ready states, and cleanup

Component Events - Automatic type-safe event handlers for lifecycle and property changes

🎯 Navigation & Routing - Built-in navigation contracts with React Router integration

🔐 Security Provider - Authentication and authorization support

Already familiar with React and MobX and wondering why dynstruct? → See Key Advantages

Quick Start

Try @actdim/dynstruct instantly in your browser without any installation:

Open in StackBlitz

Once the project loads, run Storybook to see live examples:

pnpm run storybook

The core pattern is structure-first composition: parent component structures explicitly reference child component structures. All dependencies are visible at the type level — before writing a single line of runtime code. See Core Concepts for the full picture.

Installation

npm install @actdim/dynstruct

Peer Dependencies

This package requires the following peer dependencies: For message bus functionality, install @actdim/msgmesh.

npm install react react-dom mobx mobx-react-lite mobx-utils \
  @actdim/msgmesh @actdim/utico react-router react-router-dom \
  rxjs uuid path-to-regexp jwt-decode http-status

Or with pnpm:

pnpm add @actdim/dynstruct @actdim/msgmesh @actdim/utico \
  react react-dom mobx mobx-react-lite mobx-utils \
  react-router react-router-dom rxjs uuid path-to-regexp \
  jwt-decode http-status

Getting Started (React)

Note: All examples below are for the React implementation. SolidJS and Vue.js versions will have similar structure with framework-specific adapters.

1. Define Child Components

First, create simple child components (Button and Input):

// React implementation
import { ComponentStruct, ComponentDef, ComponentParams, Component, ComponentModel } from '@actdim/dynstruct/componentModel/contracts';
import { useComponent, toReact } from '@actdim/dynstruct/componentModel/react';
import { AppMsgStruct } from '@actdim/dynstruct/appDomain/appContracts';

// Button component structure
type ButtonStruct = ComponentStruct<AppMsgStruct, {
    props: {
        label: string;
        onClick: () => void;
    };
}>;

// Button hook-constructor
const useButton = (params: ComponentParams<ButtonStruct>) => {
    let c: Component<ButtonStruct>;
    let m: ComponentModel<ButtonStruct>;

    const def: ComponentDef<ButtonStruct> = {
        props: {
            label: 'Click',
            onClick: () => {},
        },
        view: () => (
            <button onClick={m.onClick}>{m.label}</button>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

// Input component structure
type InputStruct = ComponentStruct<AppMsgStruct, {
    props: {
        value: string;
        onChange: (v: string) => void;
    };
}>;

// Input hook-constructor
const useInput = (params: ComponentParams<InputStruct>) => {
    let c: Component<InputStruct>;
    let m: ComponentModel<InputStruct>;

    const def: ComponentDef<InputStruct> = {
        props: {
            value: '',
            onChange: (_: string) => {},
        },
        view: () => (
            <input
                value={m.value}
                onChange={(e) => m.onChange(e.target.value)}
            />
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

2. Define Parent Component with Children

The parent component structure references child structures - this makes dependencies explicit:

// React implementation
// Parent component structure with children
type CounterPanelStruct = ComponentStruct<AppMsgStruct, {
    props: {
        counter: number;
        message: string;
    };
    children: {
        incrementBtn: ButtonStruct;    // References child structure
        resetBtn: ButtonStruct;        // References child structure
        messageInput: InputStruct;     // References child structure
    };
}>;

// Parent hook-constructor
const useCounterPanel = (params: ComponentParams<CounterPanelStruct>) => {
    let c: Component<CounterPanelStruct>;
    let m: ComponentModel<CounterPanelStruct>;

    const def: ComponentDef<CounterPanelStruct> = {
        props: {
            counter: 0,
            message: 'Hello',
        },
        // Component events with full IntelliSense support!
        events: {
            // Automatically typed event for 'message' property
            onChangeMessage: (oldValue, newValue) => {
                console.log(`Message changed from "${oldValue}" to "${newValue}"`);
                // You can also update other properties or sync with children
            },

            // Event for 'counter' property
            onChangeCounter: (oldValue, newValue) => {
                if (newValue > 10) {
                    m.message = 'Counter is getting high!';
                }
            }
        },
        // Children are created at runtime via their hook-constructors
        children: {
            incrementBtn: useButton({
                label: 'Increment',
                onClick: () => { m.counter++; }
            }),
            resetBtn: useButton({
                label: 'Reset',
                onClick: () => { m.counter = 0; }
            }),
            messageInput: useInput({
                value: bind(() => m.message, v => { m.message = v; })
            })
        },
        view: () => (
            <div>
                <h3>{m.message}</h3>
                <p>Counter: {m.counter}</p>
                <c.children.IncrementBtn />
                <c.children.ResetBtn />
                <c.children.MessageInput />
            </div>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

3. Using Components

Primary way - Use as children in parent components (shown above).

Alternative way - Use toReact adapter for integration with standard React:

// React adapter
// Create React adapter (only when needed for standard React integration)
export const CounterPanel = toReact(useCounterPanel);

// Now can be used in standard React components
function App() {
    return (
        <div>
            <CounterPanel counter={5} message="Welcome!" />
        </div>
    );
}

Note: toReact is an adapter for compatibility with standard React components. The primary pattern is to use components through children property in parent structures, as this makes all dependencies explicit at the type level.

Core Concepts

Component Structure

The first step in the dynstruct architectural pattern is defining the component structure. The base generic class ComponentStruct acts as a structural constructor — a scaffold that provides constraints, hints, and full IntelliSense to the developer when forming the base type contract. All derived component model APIs are built on top of this contract through TypeScript's advanced type system.

Crucially, component structures are pure type declarations — they require no implementations (hook-constructors), only type information. This means you can define the entire application's component hierarchy at the type level before writing a single line of runtime code.

type Struct = ComponentStruct<
    AppMsgStruct,
    // The message bus structure that will serve as the basis for the
    // component's msgBroker operation. This type maps to Struct["msg"].
    {
        props: {
            // Names and types of component properties that will be reactive
            // (including nested values) after the component is created.
            counter: number;
            message: string;
            items: Item[];
        };

        actions: {
            // Method signatures that perform operations on properties.
            // Action calls are optimized for batching reactive property
            // change application.
            increment: () => void;
            updateMessage: (text: string) => void;
        };

        children: {
            // Names and types of child components.
            // Types are base structures (similar to this one) of other components.
            // No implementations (hook-constructors) are required to form the
            // structure — only type data.
            header: HeaderStruct;
            footer: FooterStruct;
            todoList: TodoListStruct;
        };

        msgScope: {
            // Message bus channel names this component works with.
            // Divided into sections: subscribe, publish, provide.
            // See @actdim/msgmesh documentation for details.
            //
            // msgScope narrows the bus working area (it is normal to use a
            // global app-wide bus) to this component's zone of responsibility.
            // This not only makes working with the bus more convenient
            // (the namespace is not polluted by other channels), but also
            // lets you immediately see the component's message scope.

            // Channels this component subscribes to (consumes messages from)
            subscribe: AppMsgChannels<'USER-UPDATED' | 'DATA-LOADED'>;

            // Channels this component publishes messages to
            publish: AppMsgChannels<'FORM-SUBMITTED'>;

            // Channels for which this component is a response-message
            // provider ("out" groups) for request-messages ("in" groups)
            provide: AppMsgChannels<'GET-USER-DATA' | 'VALIDATE-INPUT'>;
        };

        // List of effect names that will be available in this component.
        // Effect implementations are defined in ComponentDef (see below).
        effects: ['computeSummary', 'trackCounter'];
    }
>;

| Field | Description | |---|---| | props | Reactive property names and types. All declared properties (including nested values) become reactive after component creation. | | actions | Method signatures that operate on props. Action calls are optimized for batching reactive property change application. | | children | Names and types of child components. Uses base structures of other components — no implementations required, only type data. | | msgScope | Message bus channels this component works with. Sections: subscribe (incoming message subscriptions), publish (outgoing message channels), provide (response provider for request-messages). Narrows the global bus scope to this component's responsibility zone. See @actdim/msgmesh documentation. | | effects | List of effect names available in this component. Implementations are defined in ComponentDef. |

Component Definition

The component implementation is created inside a hook-constructor function (use<ComponentName>) using the ComponentDef<Struct> type. This is where you provide the runtime implementation for the contract declared in the structure:

const useMyComponent = (params: ComponentParams<Struct>) => {
    let c: Component<Struct>;
    let m: ComponentModel<Struct>;

    const def: ComponentDef<Struct> = {
        // Component type identifier used when registering in the component tree.
        // Also used to form the component instance ID, which can be used
        // (manually) as an HTML id in the component's markup.
        regType: 'MyComponent',

        props: {
            // Default values for properties. Provided params are applied
            // automatically by the framework — no need to read them here.
            counter: 0,
            message: 'Hello',
            items: [],
        },

        actions: {
            // Method implementations (signatures match those declared
            // in the component structure). Actions perform operations on
            // properties; their calls are optimized for batching reactive
            // property change application.
            increment: () => { m.counter++; },
            updateMessage: (text) => { m.message = text; },
        },

        effects: {
            // Effect implementations. Effects are auto-tracking reactive
            // functions that re-run automatically whenever any reactive
            // property accessed inside them changes.
            //
            // Effects are accessed on the component instance by name via
            // the `effects` property (e.g. c.effects.computeSummary).
            //
            // An effect runs immediately when the component is created and
            // can later be manually paused, resumed, or stopped entirely.
            computeSummary: (component) => {
                // Re-runs whenever m.items changes
                m.message = `Total items: ${m.items.length}`;
                // Return an optional cleanup function
                return () => { /* cleanup */ };
            },
            trackCounter: (component) => {
                // Re-runs whenever m.counter changes
                if (m.counter > 100) m.message = 'Counter is high!';
            },
        },

        children: {
            // Child component instances created via their hook-constructors
            // (use*). When creating children you can initialize their
            // properties, including bindings, and assign additional (external)
            // event handlers.
            header: useHeader({ title: bind(() => m.message) }),
            footer: useFooter({ year: 2025 }),
            todoList: useTodoList({
                items: bind(
                    () => m.items,
                    v => { m.items = v; }
                ),
            }),
        },

        events: {
            // Component event handlers. The type system offers a choice of
            // all supported events. See the Component Events section below
            // for the full list.
            onInit: (component) => { console.log('Initialized'); },
            onChangeCounter: (value) => {
                if (value > 100) m.message = 'Counter is high!';
            },
        },

        msgBroker: {
            // Message bus handlers declared in the component structure.
            // Defined by channels and groups in sections:
            provide: {
                // Response-message providers ("out" groups)
                // for request-messages ("in" groups).
                'GET-USER-DATA': {
                    in: {
                        callback: (msgIn, headers, component) => {
                            return { userId: '1', name: 'Alice', email: '[email protected]' };
                        },
                    },
                },
            },
            subscribe: {
                // Handlers for incoming messages.
                'USER-UPDATED': {
                    in: {
                        callback: (msg, component) => {
                            console.log('User updated:', msg.payload);
                        },
                        componentFilter: ComponentMsgFilter.FromDescendants,
                    },
                },
            },
        },

        // Message bus instance. If not specified, the bus from the
        // available component model context will be used.
        // The bus must be compatible with the message structure
        // declared in the component structure.
        msgBus: undefined,

        // Component render function that produces the view (JSX).
        // Uses automatic JSX components created for child components
        // (accessed via component.children.*.View).
        // This function is intended to be compact since all wiring
        // and initialization code is distributed across other
        // definition areas. Inline props should be used only in a
        // functional wrapper component created via toReact (or a
        // similar adapter) to integrate dynstruct into regular
        // components (for example, at the app root level). Using such
        // components directly inside dynstruct view functions
        // keeps child parameter wiring mixed into render code instead
        // of a dedicated children block, reduces compactness and
        // readability, increases side-effect risk, and harms
        // predictable reactivity.
        view: () => (
            <div>
                <h3>{m.message}</h3>
                <p>Counter: {m.counter}</p>
                <c.children.Header />
                <c.children.TodoList />
                <c.children.Footer />
            </div>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

| Field | Description | |---|---| | regType | Component type identifier used when registering in the component tree. Also used to form the instance ID (can be used as HTML id). | | props | Default property values. Provided params are applied automatically by the framework on top of these defaults — no manual params.x ?? default needed. | | actions | Method implementations (signatures match the structure). Optimized for batching reactive property changes. | | effects | Effect implementations — methods that run automatically when any property accessed within them changes. An effect runs on component creation and can be paused, resumed, or stopped via c.effects.<name>. Returns an optional cleanup function. | | children | Child component instances created via hook-constructors (use*). Properties can be initialized with values or bindings; external event handlers can be assigned. | | events | Component event handlers (lifecycle, property changes). See Component Events. | | msgBroker | Message bus handlers for channels declared in the structure. Contains provide (response providers) and subscribe (message handlers) sections. Handlers are registered through the component-scoped msgBus, so unmount cleanup semantics are applied to broker channels as well. | | msgBus | Explicit message bus instance. If omitted, the bus from the component model context is used. Must be compatible with the declared message structure. The component uses a lifecycle-scoped msgBus wrapper: on unmount, subscriptions are automatically canceled and pending requests are aborted via AbortSignal. | | view | Render function () => JSX. Uses the closed-over c and m variables declared at the top of the hook-constructor. Children rendered as <c.children.Name /> (Capitalized). Intended to be compact — logic lives in other definition areas. | | fallbackView | Optional error fallback (props, component) => JSX. Rendered instead of view when useErrorBoundary: true is set and the component catches an error. |

Implementation Extension (ComponentStructExt)

When a component's implementation grows complex, you can extend the component struct inside the hook-constructor using ComponentStructExt. The extended type is only visible within that function; callers receive Component<Struct> and see only the original public API.

This cleanly separates the external contract (what callers pass and observe) from the internal implementation (what the hook needs to do its work). It replaces the React pattern of splitting concerns between props (external) and useState/useReducer (internal) — but through a single consistent mechanism, just with different scopes.

What it enables:

  • Private reactive props — internal state that only the implementation reads or writes.
  • Internal children — lightweight React.FC fragments used to decompose a large view into named, readable sections. The parent view becomes a clean slot list.
  • Internal effects — effects that drive internal state and are never exposed.
export const useComponentStateExample = (params: ComponentParams<Struct>): Component<Struct> => {
    // Invisible to callers — only valid inside this function
    type ImplStruct = ComponentStructExt<
        Struct,
        {
            props: {
                data: string[];
                userInfo: {
                    email: string;
                    avatarUrl: string;
                };
            };
            children: {
                avatarView: AvatarViewStruct;
                section1: React.FC; // named fragment for the form section
                section2: React.FC; // named fragment for the busy-state section
            };
        }
    >;

    let c: Component<ImplStruct>;
    let m: ComponentModel<ImplStruct>;

    const def: ComponentDef<ImplStruct> = {
        props: {
            data: [],
            userInfo: prop({ initialValue: { email: '', avatarUrl: '' } }),
        },
        children: {
            avatarView: useAvatarView({ 'avatar.imageUrl': bind(() => m.userInfo.avatarUrl) }),
            section1: () => <details>...form JSX...</details>,
            section2: () => <details>...busy-state JSX...</details>,
        },
        // Main view stays minimal — just slots for the two sections
        view: () => (
            <div>
                <c.children.Section1 />
                <c.children.Section2 />
            </div>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c; // returned as Component<Struct> — ImplStruct stays private
};

Full working example: src/_stories/componentModel/componentState/StateExample.tsx

Instance Internals (ComponentImpl)

Sometimes a component needs non-reactive, per-instance data — a cache, a lock, a lazily-initialized resource — that must survive re-renders but must not trigger UI updates when it changes. This is the role of the internals slot (c._), analogous to useRef in React but typed and initialized once per instance.

Declare a type Internals alias inside the hook-constructor, then pass the initial value as the third argument to useComponent. The component instance type becomes ComponentImpl<Struct, Internals> and the data is accessible via c._.

export const useStorageService = (params: ComponentParams<Struct>): Component<Struct> => {
    type Internals = {
        store?: PersistentStore;
    };

    let c: ComponentImpl<Struct, Internals>;
    let m: ComponentModel<Struct>;

    const def: ComponentDef<Struct> = {
        events: {
            onReady: async () => {
                c._.store = await PersistentStore.open(m.storeName);
            },
            onChangeStoreName: async () => {
                c._.store = await PersistentStore.open(m.storeName); // reinitialize
            },
        },
        msgBroker: {
            provide: {
                [$STORE_GET]: {
                    in: {
                        callback: async (msg) => {
                            return await c._.store.get(msg.payload.key);
                        },
                    },
                },
            },
        },
    };

    c = useComponent(def, params, {} as Internals); // 3rd arg initializes c._
    m = c.model;
    return c; // returned as Component<Struct> — Internals stays private
};

Key properties:

  • Changes to c._ fields do not trigger re-renders.
  • Each component instance has its own isolated c._ object.
  • Initialized once with the value passed as the third argument to useComponent.
  • Return Component<Struct> (not ComponentImpl) from the hook-constructor to keep internals private.

Full working example: src/services/react/StorageService.tsx

Reactive Properties

Component properties are automatically reactive after component creation with useComponent. Any changes to properties will trigger UI updates.

How reactivity works

The framework makes the component model fully reactive by default — at all levels of nesting. This includes objects assigned to props after initialization: when a new plain object is placed into a reactive prop, the framework converts it into a reactive value automatically.

This follows the same mental model as React's props and state: a change to any piece of state that was read during rendering will schedule a re-render of the component that read it. The key guarantee is that re-renders only happen when there is an actual connection between the changed value and the rendered output — values that were tracked during the last render. If a prop was never read in view, changing it will not cause a re-render.

However, on large or deeply nested object graphs this fine-grained tracking still has a cost. Every property access is instrumented, and every object placed into the model is wrapped — even if the component only uses a small fraction of the graph. This is the default trade-off: maximum safety, predictable behavior, some overhead.

When to limit reactivity

If part of the model does not need to drive the UI directly — for example, an array of raw data items that are forwarded to child components or external APIs without being iterated in view — you can opt out using prop({ reactive: ... }). See Controlling Reactivity for the available options.

If a piece of data does not need reactivity at all (a cache, a lock, a mutable counter for internal bookkeeping), do not put it in def.props. Use ComponentImpl / c._ instead — it is per-instance mutable state that is invisible to the reactivity system entirely. See Private Instance Data (ComponentImpl).

Do not use the underlying reactivity library (MobX) directly. The dynstruct component model is self-contained — def.props, def.effects, def.actions, and the event system manage all reactivity for you. Importing observable, computed, action, or autorun from MobX in component code bypasses framework guarantees and leads to unpredictable behavior. MobX is an implementation detail, not part of the public API.

Converting Model Values to Plain Objects

Model properties carry internal proxy wrappers for reactivity tracking. When passing model data to external systems (REST APIs, third-party libraries, postMessage, etc.) that expect plain objects, use toPlain:

import { toPlain } from '@actdim/dynstruct/componentModel/core';

// convert a specific prop before sending externally
externalApi.send(toPlain(m.user));

// or the entire model
const snapshot = toPlain(m);

toPlain performs a deep conversion and returns a plain JavaScript object with no reactive wrappers. It is not needed for values passed to child components or through the message bus — those are handled automatically by the framework.

const def: ComponentDef<Struct> = {
    props: {
        counter: 0,
        message: 'Hello'
    },
    actions: {
        increment: () => {
            m.counter++; // Automatically triggers re-render
        }
    }
};

const c = useComponent(def, params);
const m = c.model; // m.counter and m.message are reactive

Nested Props and Validators

Properties are not limited to top-level names. You can declare nested paths (e.g. 'userInfo.email') as keys in def.props to attach validators and descriptors to nested values. A nested path entry requires a prop(...) descriptor instead of a plain value.

const def: ComponentDef<ImplStruct> = {
    props: {
        userInfo: prop({
            initialValue: {
                email: '[email protected]',
                avatarUrl: '',
            },
        }),
        // Attach a validator to the nested path userInfo.email
        'userInfo.email': prop({
            initialValue: '[email protected]',
            validator: {
                onBlur: true,   // run when the bound input loses focus
                validate: (v: string) => {
                    const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v ?? '');
                    return valid ? undefined : { isValid: false, message: 'Invalid email' };
                },
            },
        }),
    },
};

Validator triggers:

| Option | Description | |---|---| | onBlur: true | Validates when the bound input element loses focus (via mapToEdit). | | onChange: true | Validates on every value change. |

Bindings (bind / bindProp) also support nested paths. A binding to 'userInfo.email' behaves identically to a binding to a top-level prop — it is reactive, propagates changes through the property system, and fires validation and change events.

Controlling Reactivity

By default every prop is fully reactive — the framework tracks property access and mutations at any depth. Use prop({ reactive: ... }) to opt out or limit reactivity:

| reactive | Behaviour | |---|---| | true (default) | Fully reactive. Access and mutations at any depth are tracked. | | false | Not reactive at all. Reads and writes are invisible to the reactivity system — no re-renders. | | 'shallow' | Array mutations (push, pop, splice) are tracked, but item properties are not. Useful for large lists whose items don't need individual observation. |

const def: ComponentDef<Struct> = {
    props: {
        // top-level prop — completely non-reactive
        config: prop({ initialValue: { debug: false }, reactive: false }),

        // nested path — array mutations reactive, items not tracked
        'user.tags': prop({ reactive: 'shallow' }),
    },
};

reactive: false and 'shallow' also work on pure metadata entries (without initialValue) to annotate a property that was already declared elsewhere in def.props.

Computed (Trackable) Properties

A getter in def.props declares a computed property that is automatically tracked by the framework. It re-evaluates only when its reactive dependencies change — no manual annotation needed.

Declare the prop as readonly in the struct type to match the getter-only semantics:

type Struct = ComponentStruct<AppMsgStruct, {
    props: {
        firstName: string;
        lastName: string;
        readonly fullName: string;  // computed — mark readonly
    };
}>;

const def: ComponentDef<Struct> = {
    props: {
        firstName: 'John',
        lastName: 'Smith',
        get fullName() {
            // TypeScript does not type `this` in PropertyDescriptor getters —
            // reference `m` (the reactive model) instead
            return `${m.firstName} ${m.lastName}`.trim();
        },
    },
    view: () => <div>{m.fullName}</div>,
};

fullName re-renders only when firstName or lastName actually changes.

This works for nested object properties too — define the getter on the nested object literal inside def.props and the framework propagates the computed annotation through any depth of nesting.

Example: src/_stories/componentModel/EffectDemo.tsx

Component State (m.$)

Every component model exposes a $ field of type ComponentState<TStruct> with reactive metadata about the component:

| Field | Type | Description | |---|---|---| | pendingRequestCount | number | Reactive count of unresolved c.msgBus.request(...) calls. Use in JSX to drive loading indicators. | | propState | Record<path, descriptor> | Reactive map of per-property state descriptors, keyed by property path (including nested paths). Each entry exposes error, isValid, and related validation results. Supports IntelliSense and compile-time path checking. |

// Show a loading indicator while any request is in flight
const isBusy = m.$.pendingRequestCount > 0;

// Read validation state for a nested property
const emailState = m.$.propState['userInfo.email'] ?? {};

// Use in JSX
<input
    {...c.mapToEdit('userInfo.email')}
    style={{ borderColor: emailState.error ? '#e53935' : undefined }}
/>
{emailState.error && <span style={{ color: '#e53935' }}>{emailState.error}</span>}

Full working example: src/_stories/componentModel/componentState/StateExample.tsx

Form Helpers: validate() and mapToEdit()

c.validate(propPath?) — triggers validation for one property (by path) or for all declared validators. Use before submit to block sending invalid data.

c.mapToEdit<TEl>(propPath) — returns a props object that binds a reactive property to an HTML input or select element. Wires value, onChange, and (when onBlur: true is declared for the validator) onBlur. Writes flow back through the property system so validators and onChangingX / onChangeX handlers fire as expected.

// Bind m.userInfo.email to an <input type="email">
<input type="email" {...c.mapToEdit('userInfo.email')} />

// Bind m.userInfo.avatarUrl to a <select>
<select {...c.mapToEdit<HTMLSelectElement>('userInfo.avatarUrl')}>
    <option value={avatar1}>Example 1</option>
    <option value={avatar2}>Example 2</option>
</select>

Component Events

The component model provides automatic type-safe event handlers for the component lifecycle and property changes. IntelliSense automatically suggests all available events based on the component structure.

The full set of supported events is defined by the ComponentEvents<TStruct> type and is divided into three groups: lifecycle events, global property change events, and property-specific events.

Lifecycle Events

All lifecycle handlers may be async (return MaybePromise<void>). All are wrapped by the framework's error router — both sync throws and async rejections propagate to onCatch.

| Event | React equivalent | When | |---|---|---| | onInit | — | Called once when the component instance is first created, before any render. model and children are available. Use for one-time synchronous setup that does not require the DOM. | | onLayoutReady | useLayoutEffect | Called after the component's DOM nodes are inserted into the document, before the browser paints. Runs synchronously — suitable for DOM measurements or scroll/focus operations. | | onReady | useEffect | Called after the browser has painted and the component is visible. The primary hook for async work: data fetching, subscriptions, timers. | | onLayoutDestroy | useLayoutEffect cleanup | Called synchronously when the component is about to be removed from the DOM. Mirror of onLayoutReady. | | onDestroy | useEffect cleanup | Called after the component is unmounted. Release resources here. The component-scoped msgBus abort signal fires, automatically canceling subscriptions and pending request(...) calls. | | onCatch | — | Central error handler. Called for errors from any lifecycle hook, effect body, property event handler, binding, or msgBus callback. See Error Handling. |

const def: ComponentDef<Struct> = {
    events: {
        // Once, before first render — sync setup only, no DOM access
        onInit: (component) => {
            console.log('created:', component.id);
        },

        // After DOM insertion, before paint — sync, for DOM measurements
        onLayoutReady: (component) => {
            const el = document.getElementById(component.id);
            console.log('height:', el?.offsetHeight);
        },

        // After paint, component visible — primary async hook
        onReady: async (component) => {
            await loadInitialData();
        },

        // Before DOM removal — sync
        onLayoutDestroy: (component) => {
            console.log('layout destroy');
        },

        // After unmount — release resources
        onDestroy: (component) => {
            console.log('destroyed');
        },

        // Catches errors from lifecycle hooks, effects, property handlers,
        // bindings, and msgBus callbacks
        onCatch: (component, error) => {
            console.error('component error:', error);
        },
    }
};

Error Handling

onCatch is the central error handler for a component. It is called in two situations:

1. Errors from lifecycle hooks and other framework-managed calls

The framework routes errors to onCatch from every call it wraps — synchronous throws and async rejections alike. This includes lifecycle hooks (onInit, onLayoutReady, onReady, onLayoutDestroy, onDestroy), effect bodies (def.effects), property event handlers, bindings, and msgBus callbacks. This is the primary pattern for handling errors from initial data loads or setup logic:

type Struct = ComponentStruct<AppMsgStruct, {
    props: { status: 'idle' | 'loading' | 'error'; errorMessage: string };
}>;

const useMyComponent = (params: ComponentParams<Struct>) => {
    let c: Component<Struct>;
    let m: ComponentModel<Struct>;

    async function load() {
        m.status = 'loading';
        m.errorMessage = '';
        await fetchData(); // may throw
        m.status = 'idle';
    }

    // Shared handler — used by both onCatch and manual try/catch below
    function handleError(err: unknown) {
        m.status = 'error';
        m.errorMessage = err instanceof Error ? err.message : String(err);
    }

    const def: ComponentDef<Struct> = {
        props: { status: 'idle', errorMessage: '' },
        events: {
            onReady: async () => {
                await load();
                // if load() throws, the framework calls onCatch automatically
            },
            onCatch: (_, err) => {
                handleError(err);
            },
        },
        view: () => (
            <div>
                {m.status === 'loading' && <span>Loading…</span>}
                {m.status === 'error' && <span style={{ color: 'red' }}>{m.errorMessage}</span>}
                {/* load() calls fetchData() — a plain async fn, not a dynstruct API.
                    It is not wrapped automatically, so we catch manually here. */}
                <button
                    disabled={m.status === 'loading'}
                    onClick={async () => {
                        try {
                            await load();
                        } catch (err) {
                            handleError(err); // same handler, called manually
                        }
                    }}
                >
                    Retry
                </button>
            </div>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

When onCatch is called automatically: The framework routes errors to onCatch for every call that crosses the dynstruct API boundary — lifecycle hooks (onInit, onLayoutReady, onReady, onLayoutDestroy, onDestroy), actions (def.actions — wrapped by the framework so action calls are batched automatically and errors propagate to onCatch), property event handlers (onGetX, onChangingX, onChangeX, onPropChanging, onPropChange), binding get/set functions, effect bodies, and msgBus subscriber/provider callbacks and request(...) calls.

When manual try/catch is needed: A raw function called in an onClick that does not use any dynstruct API. In the pattern above, load() calls a plain fetchData() helper — so errors from the button click are caught manually with the same shared handleError function.

2. Render-time errors via the error boundary

When a component's view function throws during React rendering, the error boundary (enabled by default via useErrorBoundary: true) catches it and calls onCatch. The view is then replaced by either the built-in fallback UI or a custom fallbackView if one is defined.

const def: ComponentDef<Struct> = {
    // Custom view shown instead of the broken component
    fallbackView: (props, component) => (
        <div style={{ color: 'red' }}>Component failed to render.</div>
    ),
    // Also receives render-time errors
    events: {
        onCatch: (_, err) => {
            console.error('Render error caught:', err);
        },
    },
    view: () => {
        if (someCondition) throw new Error('Render failed');
        return <div>Normal content</div>;
    },
};

Set useErrorBoundary: false to opt out of the boundary (useful when onCatch handles async errors and a render-time throw should propagate normally).

Global Property Change Events

These events fire when any reactive property changes. Useful for cross-cutting concerns like logging, validation, or synchronization.

| Event | Description | |---|---| | onPropChanging | Fires before any reactive property changes. Return false to cancel the change. | | onPropChange | Fires after any reactive property has changed. |

const def: ComponentDef<Struct> = {
    events: {
        // Before ANY property changes — return false to cancel
        onPropChanging: (propName, oldValue, newValue) => {
            console.log(`Property ${propName} changing:`, oldValue, '->', newValue);
            return newValue !== null; // cancel if null
        },

        // After ANY property has changed
        onPropChange: (propName, value) => {
            console.log(`Property ${propName} changed to:`, value);
        }
    }
};

Property-Specific Events (Automatically Typed)

For each property declared in props, the type system automatically generates typed event handler slots. IntelliSense provides suggestions for all properties.

| Event pattern | Description | |---|---| | onGet<PropName> | Getter interceptor — called when the property is read. Returns the value. | | onChanging<PropName> | Fires before a specific property changes. Return false to cancel the change. | | onChange<PropName> | Fires after a specific property has changed. |

type MyStruct = ComponentStruct<AppMsgStruct, {
    props: {
        counter: number;
        text: string;
        isActive: boolean;
    };
}>;

const def: ComponentDef<MyStruct> = {
    props: {
        counter: 0,
        text: '',
        isActive: false
    },
    events: {
        // IntelliSense automatically suggests these based on props!

        // Getter interceptor — called when property is read
        onGetCounter: () => {
            console.log('Counter was read');
            return m.counter;
        },

        // Before a specific property changes — return false to cancel
        onChangingText: (oldValue, newValue) => {
            console.log('Text changing:', oldValue, '->', newValue);
            return newValue.trim(); // sanitize input
        },

        // After a specific property has changed
        onChangeText: (value) => {
            console.log('Text changed to:', value);
            c.children.child1.model.value = value;
        },

        onChangeIsActive: (value) => {
            if (value) {
                console.log('Component activated!');
            }
        }
    }
};

Real-World Example

type FormStruct = ComponentStruct<AppMsgStruct, {
    props: {
        email: string;
        password: string;
        isValid: boolean;
    };
    children: {
        emailInput: InputStruct;
        passwordInput: InputStruct;
    };
}>;

const useForm = (params: ComponentParams<FormStruct>) => {
    let c: Component<FormStruct>;
    let m: ComponentModel<FormStruct>;

    const def: ComponentDef<FormStruct> = {
        props: {
            email: '',
            password: '',
            isValid: false
        },
        events: {
            // Validate email after it changes — onChange receives only the new value
            onChangeEmail: (value) => {
                const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                m.isValid = emailRegex.test(value) && m.password.length >= 6;
            },

            // Validate password after it changes
            onChangePassword: (value) => {
                m.isValid = m.email.includes('@') && value.length >= 6;
            },

            // Sanitize input before setting — onChanging receives (oldValue, newValue)
            onChangingEmail: (oldValue, newValue) => {
                return newValue.toLowerCase().trim();
            }
        },
        children: {
            emailInput: useInput({
                value: bind(() => m.email, v => { m.email = v; })
            }),
            passwordInput: useInput({
                value: bind(() => m.password, v => { m.password = v; })
            })
        },
        view: () => (
            <div>
                <c.children.EmailInput />
                <c.children.PasswordInput />
                <button disabled={!m.isValid}>Submit</button>
            </div>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

Key Benefits:

  • Full TypeScript IntelliSense - event names are auto-generated from props
  • Type-safe parameters - correct types for old/new values
  • Validation and sanitization - intercept changes before they happen
  • Synchronization - keep parent and child components in sync
  • Lifecycle hooks - respond to component lifecycle stages

Effects

Coming from React? Effects here are not the equivalent of useEffect. For side effects that run after mount (data fetching, subscriptions, timers), use the onReady lifecycle event — it maps to useEffect and is async-safe. See Lifecycle Events. Effects in dynstruct auto-track reactive property reads and re-execute automatically when those values change.

Effects are auto-tracking reactive functions. An effect runs immediately when the component is created, and then re-runs automatically whenever any reactive property accessed inside it changes. Effect names must first be declared in the component structure, then implemented in ComponentDef.

Each effect is accessible on the component instance via c.effects.<name> and exposes an EffectController with three methods:

| Method | Description | |---|---| | pause() | Suspends the effect. Property changes are ignored until resumed. | | resume() | Resumes a paused effect and immediately re-evaluates it. | | stop() | Stops the effect entirely. It will not run again. |

An effect can optionally return a cleanup function that is called when the effect is stopped or the component is destroyed.

Example — computed fullName that auto-updates when firstName or lastName changes, with pause/resume control:

type Struct = ComponentStruct<AppMsgStruct, {
    props: {
        fullName: string;
        firstName: string;
        lastName: string;
        trackingEnabled: boolean;
    };
    children: {
        firstNameEdit: SimpleEditStruct;
        lastNameEdit: SimpleEditStruct;
    };
    // Declare effect names in the structure
    effects: 'trackNameChanges';
}>;

const useEffectDemo = (params: ComponentParams<Struct>) => {
    let c: Component<Struct>;
    let m: ComponentModel<Struct>;

    const def: ComponentDef<Struct> = {
        props: {
            fullName: '',
            firstName: 'John',
            lastName: 'Smith',
            trackingEnabled: true,
        },
        events: {
            // Toggle effect pause/resume via a property change event
            onChangeTrackingEnabled: (v) => {
                if (v) {
                    c.effects.trackNameChanges.resume();
                } else {
                    c.effects.trackNameChanges.pause();
                }
            },
        },
        effects: {
            // Runs immediately on creation, then re-runs whenever
            // m.firstName or m.lastName changes
            trackNameChanges: (c) => {
                m.fullName = `${m.firstName} ${m.lastName}`;
            },
        },
        children: {
            firstNameEdit: useSimpleEdit({
                value: bindProp(() => m, 'firstName'),
            }),
            lastNameEdit: useSimpleEdit({
                value: bindProp(() => m, 'lastName'),
            }),
        },
        view: () => (
            <div id={c.id}>
                <div>First Name: <c.children.FirstNameEdit /></div>
                <div>Last Name: <c.children.LastNameEdit /></div>
                <div>Full Name: {m.fullName}</div>
                {m.trackingEnabled
                    ? <button onClick={() => { m.trackingEnabled = false; }}>Pause</button>
                    : <button onClick={() => { m.trackingEnabled = true; }}>Resume</button>
                }
            </div>
        ),
    };

    c = useComponent(def, params);
    m = c.model;
    return c;
};

In this example the trackNameChanges effect accesses m.firstName and m.lastName, so it re-runs whenever either changes. Clicking Pause calls c.effects.trackNameChanges.pause(), which suspends the auto-tracking — edits to the name fields no longer update fullName until Resume is clicked.

Dynamic Content

Design philosophy: working with JSX, not against it

dynstruct is designed to amplify JSX, not replace it. The framework deliberately separates two concerns that are often tangled together:

  • Wiring (how props, bindings, and state flow between components) belongs in def.children — the useX hook call for each child component.
  • Layout (where components appear) belongs in def.view — plain JSX that reads naturally.

This separation keeps the view clean. Because bindings are declared once in the useX call inside children, the JSX in view contains no wiring noise — no onChange, no manual value={...} threading, no useCallback. Each <c.children.Name /> is just a named slot. The result is JSX that looks structurally close to HTML while all the reactive coupling lives in one predictable place.

// Wiring lives here — free from JSX
children: {
    firstNameEdit: useSimpleEdit({ value: bindProp(() => m, 'firstName') }),
    lastNameEdit:  useSimpleEdit({ value: bind(() => m.lastName, v => { m.lastName = v; }) }),
    emailEdit:     useSimpleEdit({ value: bindProp(() => m, 'email'), isValid: bind(() => !!m.email) }),
},

// Layout lives here — no binding noise
view: () => (
    <form>
        <c.children.FirstNameEdit />
        <c.children.LastNameEdit />
        <c.children.EmailEdit />
    </form>
),

The children field is not limited to full dynstruct components. It supports three patterns, each adding progressively more structure when needed.


1. Named JSX Fragment (React.FC)

The lightest option: name a piece of JSX as a child. Declare it as React.FC in the struct and provide a plain function returning JSX in def.children. Useful for extracting a fragment that reads from the parent's reactive model — keeps the main view concise without requiring a separate file or a new component.

In the view, function-typed children (both React.FC and factory children below) are accessed with a capitalized name: <c.children.Summary />.

type Struct = ComponentStruct<AppMsgStruct, {
    props: { counter: number };
    children: {
        summary: React.FC;
        buttons: React.FC;
    };
}>;

const def: ComponentDef<Struct> = {
    props: { counter: 0 },
    children: {
        summary: () => <div>Counter: {m.counter}</div>,
        buttons: () => (
            <div>
                <button onClick={() => m.counter++}>Add</button>
                <button onClick={() => (m.counter = 0)}>Reset</button>
            </div>
        ),
    },
    view: () => (
        <div>
            <c.children.Summary />
            <c.children.Buttons />
        </div>
    ),
};

This is the same pattern React uses for render props and slot components — children just makes the slots explicit and named.

2. Factory Function (Parameterized Dynamic Children)

When you need to instantiate a child component per render — for example, once per item in a list — declare the child as a factory function. The function accepts JSX props and returns a component struct. The framework creates and tracks a separate component instance for each unique key.

This is how dynstruct handles dynamic lists: the factory child is a first-class component with its own model and bindings, but it does not require a separate file or standalone component definition.

type Struct = ComponentStruct<AppMsgStruct, {
    props: { counter: number; text: string };
    children: {
        // Factory: accepts JSX props, returns a component struct
        dynEdit: (props: { value?: string }) => SimpleEditStruct;
    };
}>;

const def: ComponentDef<Struct> = {
    props: { counter: 0, text: 'bar' },
    children: {
        dynEdit: (params) => useSimpleEdit({ value: params.value }),
    },
    view: () => (
        <ul>
            {Array.from({ length: m.counter }).map((_, i) => (
                <li key={i}>
                    {/* key prop drives instance identity — same key = same component instance */}
                    <c.children.DynEdit key={i} value={m.text} />
                </li>
            ))}
        </ul>
    ),
};

The factory receives the JSX props as its params argument on each render. Bindings passed here (e.g. value: params.value) update the child's model reactively, just like any other binding.

3. DynamicContent Component

When a child needs a typed reactive data slot and a custom render callback inside a proper dynstruct component, use useDynamicContent. It provides a component with a reactive data prop and a render function, so the content re-renders when the bound data changes.

import { DynamicContentStruct, useDynamicContent } from '@actdim/dynstruct/componentModel/DynamicContent';

type Struct = ComponentStruct<AppMsgStruct, {
    props: { text: string };
    children: {
        content: DynamicContentStruct<string, AppMsgStruct>;
    };
}>;

const def: ComponentDef<Struct> = {
    props: { text: 'hello' },
    children: {
        content: useDynamicContent<string>({
            data: bindProp(() => m, 'text'),
            render: (_, dc) => <>{dc.model.data}</>,
        }),
    },
    view: () => <c.children.Content />,
};

DynamicContentStruct is generic: DynamicContentStruct<TData, TMsgStruct>. The data prop holds typed data bound to a parent property, and render produces the JSX.

Summary

| Pattern | Structure type | Access in view | Use case | |---|---|---|---| | Named JSX fragment | React.FC | <c.children.Name /> | Named inline fragments, keep main view concise | | Factory function | (params) => ChildStruct | <c.children.Name key={...} prop={...} /> | Dynamic instances per render (lists, grids) | | DynamicContent | DynamicContentStruct<TData> | <c.children.Name /> | Typed reactive data with typed render callback | | Component structure | SomeChildStruct | <c.children.Name /> | Full dynstruct component with its own model and effects |

Naming convention: All children are rendered in JSX using a Capitalized name: <c.children.Name />. This is a JSX shortcut — instead of writing <c.children.avatarView.View /> you write the shorter <c.children.AvatarView />. For full dynstruct component children (ComponentStruct types), the camelCase name additionally gives access to the complete component instance — model, effects, and nested children: c.children.avatarView.model, c.children.avatarView.effects. For React.FC and factory function children only the Capitalized JSX shortcut exists, since these are lightweight fragments without their own component instance.

Component Wiring

Components exchange data and coordinate behavior through four complementary mechanisms. Each has a different coupling level and is appropriate for different scenarios.

1. Direct Bindings (bind / bindProp)

Bindings create a live two-way link between a child prop and a parent's reactive state. They are declared when creating children inside def.children and make the wiring explicit at the definition level — JSX stays clean.

bind(getter, setter?) — full control with a custom getter and optional setter. The getter can compute a derived value:

children: {
    // Derived value — child sees computed result, setter writes back to source
    fullNameEdit: useSimpleEdit({
        value: bind(
            () => `${m.firstName} ${m.lastName}`,
            (v) => {
                const [first, ...rest] = v.split(' ');
                m.firstName = first;
                m.lastName = rest.join(' ');
            },
        ),
    }),

    // Read-only derived: no setter → child prop is read-only
    lastNameEdit: useSimpleEdit({
        value: bind(() => m.lastName.toUpperCase()),
    }),
}

bindProp(target, prop) — shortcut for binding directly to a single reactive property; getter and setter are generated automatically:

children: {
    firstNameEdit: useSimpleEdit({
        value: bindProp(() => m, 'firstName'),  // reads m.firstName, writes m.firstName
    }),
}

Why bindings must use lazy getters — never pass model directly:

def.children is evaluated before c = useComponent(...) and m = c.model are assigned. Passing m directly copies undefined:

// ❌ WRONG — m is undefined when def.children is evaluated
children: {
    contactEdit: useContactEdit({ contact: m }),
}
c = useComponent(def, params);
m = c.model;  // too late

bind(() => m) / bindProp(() => m, 'prop') work because the closure is evaluated lazily during render, when m is already set:

// ✅ CORRECT
children: {
    firstNameEdit: useSimpleEdit({ value: bindProp(() => m, 'firstName') }),
}
c = useComponent(def, params);
m = c.model;  // m is set before any render

2. Direct Model Mutation and onChangeX Handlers

For tighter coordination, parent and children can access each other's models directly and react to property changes through typed event handlers:

events: {
    // Fires after m.email changes — update derived state reactively
    onChangeEmail: (newValue) => {
        m.isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newValue);
        // Can also reach into a child's model:
        c.children.submitButton.model.isDisabled = !m.isEmailValid;
    },
},

This is well-suited for intra-component coordination — a parent reacting to one of its own props and updating another child. Avoid reaching into deeply nested siblings; use the message bus for cross-branch communication.

3. Object-level Bindings

bind(() => m) passes the entire parent model as a prop to a child. The child reads and writes directly on the parent's reactive proxy:

children: {
    // contactEdit reads/writes m.firstName, m.lastName, m.email on the parent model
    contactEdit: useContactEdit({
        contact: bind(() => m),
        isReadOnly: true,
    }),
}

This is convenient for editor components that take a data object and display/edit all its fields without needing a separate binding for each field. Reads are tracked reactively; writes trigger the parent's onChange handlers.

⚠️ Coupling trade-off: binding a child to the parent's full model tightens the coupling significantly — the child implicitly depends on the parent's model shape. This can make the child difficult to reuse and the data flow harder to trace. Use it when both components are designed together and the relationship is intentional. In general, prefer binding individual props and keep children structure declarations honest about what is actually used.

4. Message Bus Communication

The message bus enables loose coupling — components interact without holding references to each other. This is the right tool for cross-branch communication and integration with services.

See Message Bus Communication for a full reference. The key distinction for wiring is the scope of message routi