@actdim/dynstruct
v1.3.1
Published
A type-safe component system for large-scale apps: explicit dependencies, message bus communication, and structure-first, declarative design
Maintainers
Readme
@actdim/dynstruct
Build scalable applications with dynamic structured components, explicit wiring, and decoupled message flow. Keep architecture clean and modular.
Table of Contents
- Overview
- Framework Support
- Features
- Quick Start
- How It Works
- Installation
- Getting Started (React)
- Key Advantages (React Examples)
- Core Concepts
- More Examples (React)
- Architecture
- API Reference
- Storybook Examples
- Development
- Package Management
- Contributing
- License
- Author
- Repository
- Issues
- Keywords
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 (with MobX for reactivity)
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
Quick Start
Try @actdim/dynstruct instantly in your browser without any installation:
Once the project loads, run Storybook to see examples:
pnpm run storybookHow It Works
The core pattern in dynstruct is structure-first composition where parent component structures explicitly reference child component structures. This makes all dependencies visible at the type level.
Installation
npm install @actdim/dynstructPeer 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-statusOr 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-statusGetting 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>) => {
const def: ComponentDef<ButtonStruct> = {
props: {
label: params.label ?? 'Click',
onClick: params.onClick ?? (() => {})
},
view: (_, c) => (
<button onClick={c.model.onClick}>{c.model.label}</button>
)
};
return useComponent(def, params);
};
// Input component structure
type InputStruct = ComponentStruct<AppMsgStruct, {
props: {
value: string;
onChange: (v: string) => void;
};
}>;
// Input hook-constructor
const useInput = (params: ComponentParams<InputStruct>) => {
const def: ComponentDef<InputStruct> = {
props: {
value: params.value ?? '',
onChange: params.onChange ?? (() => {})
},
view: (_, c) => (
<input
value={c.model.value}
onChange={(e) => c.model.onChange(e.target.value)}
/>
)
};
return useComponent(def, params);
};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: params.counter ?? 0,
message: params.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: (_, c) => (
<div>
<h3>{m.message}</h3>
<p>Counter: {m.counter}</p>
{/* Use children via c.children.xxx.View */}
<c.children.incrementBtn.View />
<c.children.resetBtn.View />
<c.children.messageInput.View />
</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.
Key Advantages (React Examples)
Note: Examples in this section demonstrate the React implementation.
Clean JSX Without Clutter
The combination of bindings (bind), events, and .View wrappers creates clean, readable JSX that clearly shows component structure without logic clutter:
// React example
// ❌ Traditional React - cluttered with inline handlers and logic
<div>
<h3>{message}</h3>
<p>Counter: {counter}</p>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
<button onClick={() => setCounter(0)}>Reset</button>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
// ✅ dynstruct - clean JSX showing structure
<div>
<h3>{m.message}</h3>
<p>Counter: {m.counter}</p>
<c.children.incrementBtn.View />
<c.children.resetBtn.View />
<c.children.messageInput.View />
</div>Performance Problems in Traditional React
Problem 1: Inline Functions Break Memoization
// ❌ PROBLEM: New function created on every render
function TodoList({ todos }) {
const [filter, setFilter] = useState('');
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
{todos.map(todo => (
<ExpensiveTodoItem
key={todo.id}
todo={todo}
// NEW FUNCTION on every render - breaks React.memo!
onToggle={() => toggleTodo(todo.id)}
/>
))}
</div>
);
}
// React.memo is USELESS here - onToggle is always new
const ExpensiveTodoItem = React.memo(({ todo, onToggle }) => {
console.log('Render:', todo.id); // Logs on EVERY keystroke in filter!
return <div onClick={onToggle}>{todo.text}</div>;
});Result: Every keystroke in filter input re-renders ALL todo items, even though they haven't changed.
Problem 2: Inline Objects Break Memoization
// ❌ PROBLEM: New object created on every render
function UserTable({ users }) {
const [sort, setSort] = useState('name');
return (
<Table
data={users}
// NEW OBJECT on every render!
config={{ sortable: true, filterable: true }}
// NEW OBJECT on every render!
style={{ padding: 10, margin: 5 }}
/>
);
}
// React.memo is USELESS - config and style are always new references
const Table = React.memo(({ data, config, style }) => {
console.log('Table rendered'); // Renders constantly!
return <table style={style}>...</table>;
});Problem 3: useCallback/useMemo Boilerplate
// ✅ "Fixed" with hooks, but verbose and error-prone
function TodoList({ todos }) {
const [filter, setFilter] = useState('');
// Must wrap in useCallback
const handleToggle = useCallback((id) => {
toggleTodo(id);
}, [toggleTodo]); // Don't forget dependencies!
// Must wrap in useMemo
const config = useMemo(() =>
({ sortable: true, filterable: true }), []
);
const style = useMemo(() =>
({ padding: 10, margin: 5 }), []
);
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
{todos.map(todo => (
<ExpensiveTodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
config={config}
style={style}
/>
))}
</div>
);
}Issues:
- Verbose boilerplate everywhere
- Easy to forget dependencies
- Hard to maintain
- Still need to wrap everything carefully
MobX Reactivity Pitfalls
While MobX is capable, it has subtle issues that cause unexpected re-renders and are hard to debug:
Problem 1: Computed Returns New Object
// ❌ computed recalculates on dependency changes
class UserStore {
user = { name: "Pavel", email: "[email protected]" };
constructor() {
makeAutoObservable(this);
}
get userViewModel() {
// ❌ Returns NEW OBJECT every time
return {
name: this.user.name,
};
}
}
const userStore = new UserStore();
export const Header = observer(() => {
const vm = userStore.userViewModel; // NEW OBJECT every render!
return <div>Hello, {vm.name}</div>;
});
// Passing to child components breaks memoization
const App = observer(() => {
const vm = userStore.userViewModel; // NEW reference
return (
<div>
{/* ChildComponent re-renders ALWAYS, even with React.memo! */}
<ChildComponent user={vm} />
</div>
);
});
const ChildComponent = React.memo(({ user }) => {
console.log('Child rendered'); // Logs constantly!
return <div>{user.name}</div>;
});Issue: Computed returns new object each time, even if fields are the same. React sees new reference, so React.memo is useless. When you pass this object to child components, everything "falls apart" with constant re-renders.
Problem 2: Accidental Reactive Dependencies
// ❌ Reading observables creates unwanted subscriptions
export const UsersList = observer(() => {
const users = userStore.users
.filter(u => u.isActive) // 👈 reading isActive on ALL users
.map(u => u.name); // 👈 reading name on ALL users
return <div>{users.join(", ")}</div>;
});Issue: Now changing isActive or name on ANY user triggers re-render of the entire list.
Common causes of unwanted subscriptions:
- ❌
toJS(observable)- reads all nested properties - ❌
{ ...observableObject }- spread operator reads all properties - ❌
Object.keys/values/entries(observable)- reads all properties - ❌
JSON.stringify(observable)- reads everything deeply - ❌
map/filter/reduceon observable arrays directly in render - creates subscriptions to all items - ❌ Returning new objects from
computed- breaks React.memo (see Problem 1)
Problem 3: Complex Combinations
// ❌ Combining observable, computed, autorun gets complex quickly
class UserStore {
@observable users = [];
@observable filter = '';
@observable sortOrder = 'asc';
@computed get filteredUsers() {
return this.users.filter(u => u.name.includes(this.filter));
}
@computed get sortedUsers() {
return this.filteredUsers.slice().sort((a, b) =>
this.sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
);
}
constructor() {
// Need runInAction for mutations
autorun(() => {
if (this.filter.length > 3) {
runInAction(() => {
this.sortOrder = 'asc';
});
}
});
}
}Issues:
- Need
runInActionfor mutations inside reactions - Complex dependency chains hard to trace
- Debugging reactive flows is difficult
- Easy to create circular dependencies
- Performance issues not immediately obvious
Problem 4: RxJS Complexity
// ❌ RxJS adds another layer of complexity
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map, debounceTime, distinctUntilChanged } from 'rxjs/operators';
const users$ = new BehaviorSubject([]);
const filter$ = new BehaviorSubject('');
const filteredUsers$ = combineLatest([users$, filter$]).pipe(
debounceTime(300),
map(([users, filter]) => users.filter(u => u.name.includes(filter))),
distinctUntilChanged()
);
// Component must subscribe/unsubscribe
const UserList = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
const sub = filteredUsers$.subscribe(setUsers);
return () => sub.unsubscribe(); // Don't forget cleanup!
}, []);
return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
};Issues:
- Entire paradigm to learn (operators, streams, subscriptions)
- Multiple abstractions (Subject, Observable, Operators)
- Manual subscription management
- Hard to debug async flows
- Easy to create memory leaks
Problem 5: Passing Objects Down the Hierarchy
Often, to quickly ship features, developers cut corners by passing objects down from parent components and using them in child components, including calling callbacks to affect upper levels.
This is not officially an anti-pattern, though in my opinion it should be considered one. It's a common way of parent-child interaction in React, but it violates component isolation and is often the cause of unnecessary re-renders, even when components seem independent by their properties.
Example:
export function Parent() {
const [count, setCount] = useState(0);
const config = { pageSize: 20 }; // ❌ new object every render
return (
<>
<button onClick={() => setCount(c => c + 1)}>+</button>
<Child config={config} />
<Child config={config} />
<Child config={config} />
</>
);
}
export function Child({ config }: { config: { pageSize: number } }) {
console.log("render child");
return <div>{config.pageSize}</div>;
}What happens when count changes:
configis created anew- New reference is created
- All children are guaranteed to re-render
Even though the children don't use count at all, they re-render because config is a new object.
Why this happens:
- Quick implementation to ship faster
- Passing parent state/objects down instead of proper component boundaries
- Callbacks passed to children to modify parent state
- Seems convenient but breaks component isolation
- Hard to spot performance issues until they accumulate
How dynstruct Solves This
Declarative and Explicit:
// React implementation
type TodoListStruct = ComponentStruct<AppMsgStruct, {
props: {
filter: string;
todos: Todo[];
};
children: {
filterInput: InputStruct;
todoItems: Record<string, TodoItemStruct>;
};
}>;
const useTodoList = (params: ComponentParams<TodoListStruct>) => {
let c: Component<TodoListStruct>;
let m: ComponentModel<TodoListStruct>;
const def: ComponentDef<TodoListStruct> = {
props: {
filter: '',
todos: []
},
// Events are explicit and declarative
events: {
onChangeFilter: (old, newFilter) => {
// No runInAction needed!
// Batching happens automatically
console.log('Filter changed:', newFilter);
}
},
// Children defined once, stable references
children: {
filterInput: useInput({
value: bind(() => m.filter, v => { m.filter = v; })
}),
todoItems: computed(() =>
Object.fromEntries(
m.todos.map(todo => [
todo.id,
useTodoItem({
text: todo.text,
completed: todo.completed,
onToggle: () => {
// Direct mutation, no runInAction!
todo.completed = !todo.completed;
}
})
])
)
)
},
view: (_, c) => (
<div>
{/* Clean JSX - no inline handlers or objects */}
<c.children.filterInput.View />
{Object.values(c.children.todoItems).map(item => (
<item.View key={item.id} />
))}
</div>
)
};
c = useComponent(def, params);
m = c.model;
return c;
};Key Benefits:
- 📋 Explicit Structure - All dependencies visible in type system
- 🧹 No Inline Functions/Objects - Stable references, no re-render issues
- ⚡ No runInAction - Mutations work directly, batching automatic
- 🎯 Declarative Events - Clear, debuggable event flow
- 🔍 Easy Debugging - No hidden reactive dependencies
- 💡 Simple Mental Model - No need to learn RxJS, no complex computed chains
- ⚙️ Automatic Optimization - Batching and re-render prevention built-in
- 📦 Minimal Overhead - Performance optimizations with clear benefits
Important Note:
We cannot claim that using dynstruct is always more optimal in terms of performance, or that it completely eliminates the possibility of shooting yourself in the foot. Where fine-grained optimization is truly necessary, it can be done selectively through other approaches - using standard React components is not prohibited!
However, the dynstruct approach creates conditions where dividing the application into isolated zones of responsibility becomes both necessary and convenient. At the same time, deviating from the rules and stepping on rakes becomes both unnecessary and inconvenient!
Using this component model encourages building applications from many small, well-designed architectural blocks and making numerous small but correct architectural decisions. This is useful not only in the long term - development becomes faster when all rules are clear and understandable, and technological boundaries and constraints are well-defined.
Why Explicit Structure Matters
The explicit separation of props, actions, and events in dynstruct makes code more manageable and maintainable:
🎯 Props as Reactive Foundation:
- Clear declaration: "these properties are reactive"
- No confusion about what triggers re-renders
- Type-safe from the start
⚙️ Actions as Methods:
- Clean separation: actions modify properties
- Easy to find where state changes happen
- Predictable data flow
📡 Events as Simple Handlers:
- Familiar concept: "something happened, react to it"
- Both property changes AND lifecycle events
- No complex reactive chains to debug
Benefits in Practice:
✅ Less Mental Overhead:
- Don't think: "Should I use
useRef?useState? Take from props?" - Don't think: "Do I need Redux with slices, reducers, enhancers?"
- Just declare props in structure - they're reactive automatically
✅ No Optimization Anxiety:
- Don't think: "Do I need
useCallbackhere?" - Don't think: "Should I wrap this in
useMemo?" - Write straightforward code - framework handles optimization
✅ Better Dependency Control:
- All dependencies visible in component structure
- Clear data flow: props → actions → events → view
- Easy to trace what affects what
✅ Easier to Maintain:
- New developers understand the pattern immediately
- Changes are localized and predictable
- Refactoring is safer with explicit types
The Problem with Too Many Degrees of Freedom
Traditional React development offers too many choices for managing state and logic:
- Should I use
useState?useRef?useReducer? - Do I need Redux? MobX? Zustand? Jotai?
- Should state live in the component? In a context? In a global store?
- How should I handle derived state?
useMemo? Computed values? - What about side effects?
useEffect? Custom hooks?
The Result: Each developer writes differently based on their:
- Experience level - beginners vs. experts make different choices
- Habits - "I always use Redux because that's what I learned"
- Patterns from previous projects - "We did it this way at my last job"
- Stereotypes and misconceptions - "Redux is better for large apps"
- Personal taste - "I prefer this pattern because it looks cleaner to me"
- Laziness - "This is faster to write, even if it's not optimal"
When your component architecture is built on many different principles and becomes complex, understanding where a problem is hiding becomes extremely difficult. Different components use different approaches, making the codebase inconsistent and hard to reason about.
When Problems Surface:
- ❌ Hard to detect - Inconsistent patterns mask the root cause
- ❌ Hard to debug - Need to understand multiple different approaches
- ❌ Hard to fix - Often requires refactoring neighboring components
- ❌ Hard to prevent - No clear "right way" to implement features
dynstruct's Solution: Consistency Through Constraints
By providing one clear way to structure components:
- ✅ All components follow the same pattern
- ✅ Problems are easier to spot (deviations stand out)
- ✅ Fixes are localized (explicit dependencies)
- ✅ New developers onboard faster (consistent approach)
- ✅ Code reviews focus on logic, not architecture debates
The framework constrains your choices in a productive way - you have fewer decisions to make, but those constraints guide you toward maintainable, scalable code.
Performance Characteristics
- ✅ Stable references -
.Viewcomponents created once - ✅ Automatic batching - Multiple property updates batched automatically
- ✅ Precise reactivity - Only properties used in view trigger re-renders
- ✅ No accidental dependencies - Can't accidentally subscribe to wrong properties
- ✅ Clear data flow - Props → Events → Model changes → View updates
This separation means you can refactor logic, add validation, or change behavior without touching your JSX markup, and without worrying about performance pitfalls.
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: {
// Initial values for properties (types match those declared
// in the component structure).
counter: params.counter ?? 0,
message: params.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: (_, c) => (
<div>
<h3>{m.message}</h3>
<p>Counter: {m.counter}</p>
<c.children.header.View />
<c.children.todoList.View />
<c.children.footer.View />
</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 | Initial property values (types match the component structure). |
| 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 producing the component's JSX view. Child components are rendered via c.children.<name>.View. Intended to be compact — logic is distributed across other definition areas. |
Reactive Properties
Component properties are automatically reactive after component creation with useComponent. Any changes to properties will trigger UI updates:
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 reactiveBindings to External State
Use bindings to connect component properties to external state or parent properties:
import { bind } from '@actdim/dynstruct/componentModel/core';
// Example 1: Binding to external state
const appState = { userName: 'John' };
const binding = bind(
() => appState.userName, // getter
(v) => { appState.userName = v; } // setter
);
// Example 2: Binding to parent component's property (typical pattern)
children: {
messageInput: useInput({
value: bind(
() => m.message, // getter from parent model
v => { m.message = v; } // setter to parent model
)
})
}Message Bus Communication
dynstruct integrates with @actdim/msgmesh, a type-safe message bus library that enables decoupled component communication.
Key Benefits
✅ Type-Safe Channels - No magic strings, full IntelliSense for channel names ✅ Local Message Namespaces - Component structure declares only relevant channels ✅ Clear Component Responsibilities - Message scope shows what component consumes/provides ✅ Component Independence - Components communicate without direct references ✅ Testability - Message bus can be easily mocked ✅ Flexible Routing - Connect any components, not just parent-child
Step 1: Define Global Message Channels
First, declare message channels at the application (or domain) level with full typing:
import { MsgStruct, MsgBus } from '@actdim/msgmesh/contracts';
import { createMsgBus } from '@actdim/msgmesh/core';
import { BaseAppMsgStruct } from '@actdim/dynstruct/appDomain/appContracts';
// Define your application's message structure
export type AppMsgStruct = BaseAppMsgStruct<AppRoutes> &
MsgStruct<{
// Event message (fire-and-forget)
'USER-CLICKED': {
in: { buttonId: string; timestamp: number };
};
// Request/response message
'GET-USER-DATA': {
in: { userId: string };
out: { userId: string; name: string; email: string };
};
// Event from child components
'FORM-SUBMITTED': {
in: { formData: Record<string, any> };
};
// Service message
'VALIDATE-EMAIL': {
in: { email: string };
out: { valid: boolean; error?: string };
};
}>;
// Create typed message bus
export type AppMsgBus = MsgBus<AppMsgStruct, ComponentMsgHeaders>;
export function createAppMsgBus() {
return createMsgBus<AppMsgStruct, ComponentMsgHeaders>({});
}
// Helper for selecting channels in component structures
export type AppMsgChannels<TChannel extends keyof AppMsgStruct | Array<keyof AppMsgStruct>> =
KeysOf<AppMsgStruct, TChannel>;Step 2: Declare Component's Message Scope
In ComponentStruct, explicitly declare which channels this component works with:
import { ComponentStruct } from '@actdim/dynstruct/componentModel/contracts';
import { AppMsgStruct, AppMsgChannels } from './appDomain';
type UserPanelStruct = ComponentStruct<
AppMsgStruct,
{
props: {
userId: string;
userData: UserData | null;
};
children: {
submitButton: ButtonStruct;
emailInput: InputStruct;
};
// Message scope - creates LOCAL namespace for this component
msgScope: {
// Channels this component SUBSCRIBES to (consumes)
subscribe: AppMsgChannels<'USER-CLICKED' | 'FORM-SUBMITTED'>;
// Channels this component PROVIDES (request/response handlers)
provide: AppMsgChannels<'GET-USER-DATA' | 'VALIDATE-EMAIL'>;
// Channels this component PUBLISHES to (sends)
publish: AppMsgChannels<'USER-UPDATED'>;
};
}
>;What This Achieves:
🎯 Local Namespace - Component only sees relevant channels, not the entire global list 📋 Clear Responsibilities - Message scope documents component's communication surface 🔒 Type Safety - TypeScript ensures only declared channels can be used in msgBroker 👀 Better Project Visibility - Easy to understand component's external dependencies 🔗 Communication Map - Shows how components connect, alongside children references
Step 3: Implement Message Handlers
In ComponentDef, implement handlers for declared channels in msgBroker:
import { ComponentDef, ComponentMsgFilter } from '@actdim/dynstruct/componentModel/contracts';
const useUserPanel = (params: ComponentParams<UserPanelStruct>) => {
let c: Component<UserPanelStruct>;
let m: ComponentModel<UserPanelStruct>;
const def: ComponentDef<UserPanelStruct> = {
props: {
userId: params.userId ?? '',
userData: null
},
msgBroker: {
// SUBSCRIBE handlers - react to events from other components
subscribe: {
'USER-CLICKED': {
in: {
callback: (msg, component) => {
console.log('User clicked button:', msg.payload.buttonId);
// Update component state
// No runInAction needed!
},
// Filter messages by source
componentFilter: ComponentMsgFilter.FromDescendants
}
},
'FORM-SUBMITTED': {
in: {
callback: (msg, component) => {
const formData = msg.payload.formData;
// Handle form submission
m.userData = { ...m.userData, ...formData };
},
componentFilter: ComponentMsgFilter.FromDescendants
}
}
},
// PROVIDE handlers - respond to requests from other components
provide: {
'GET-USER-DATA': {
in: {
callback: (msgIn, headers, component) => {
// Return response data
return {
userId: m.userId,
name: m.userData?.name ?? '',
email: m.userData?.email ?? ''
};
},
componentFilter: ComponentMsgFilter.FromDescendants
}
},
'VALIDATE-EMAIL': {
in: {
callback: (msgIn, headers, component) => {
const email = msgIn.payload.email;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return {
valid: emailRegex.test(email),
error: emailRegex.test(email) ? undefined : 'Invalid email format'
};
}
}
}
}
},
children: {
submitButton: useButton({
label: 'Submit',
onClick: () => {
// SEND event (fire-and-forget)
c.msgBus.send({
channel: 'FORM-SUBMITTED',
payload: { formData: { name: 'Alice' } }
});
}
}),
emailInput: useInput({
value: bind(() => m.userData?.email ?? '', v => {
m.userData = { ...m.userData, email: v };
})
})
},
view: (_, c) => (
<div>
<c.children.emailInput.View />
<c.children.submitButton.View />
</div>
)
};
c = useComponent(def, params);
m = c.model;
return c;
};Step 4: Send Messages and Make Requests
Components use their msgBus to send events or make requests:
// Send event (fire-and-forget)
c.msgBus.send({
channel: 'USER-CLICKED',
payload: { buttonId: 'btn-1', timestamp: Date.now() }
});
// Request/response pattern (async)
const response = await c.msgBus.request({
channel: 'GET-USER-DATA',
payload: { userId: '123' }
});
console.log('User data:', response.payload);
// Request with timeout
const validationResult = await c.msgBus.request(
{
channel: 'VALIDATE-EMAIL',
payload: { email: '[email protected]' }
},
{ timeout: 5000 }
);Unmount safety:
c.msgBusis bound to component lifecycle. When the component is unmounted, active subscriptions are automatically unsubscribed and pendingrequest(...)calls are aborted. This prevents updating state of an already destroyed component from late async responses.
Message Filtering
Use ComponentMsgFilter to control which components can send messages to your handlers:
import { ComponentMsgFilter } from '@actdim/dynstruct/componentModel/contracts';
msgBroker: {
subscribe: {
'USER-CLICKED': {
in: {
callback: (msg) => { /* ... */ },
componentFilter: ComponentMsgFilter.FromDescendants // Only from children
}
},
'ADMIN-ACTION': {
in: {
callback: (msg) => { /* ... */ },
componentFilter: ComponentMsgFilter.FromAncestors // Only from parents
}
},
'GLOBAL-EVENT': {
in: {
callback: (msg) => { /* ... */ },
componentFilter: ComponentMsgFilter.FromBus // From anywhere
}
}
}
}Available Filters:
FromDescendants- Only messages from child componentsFromAncestors- Only messages from parent/ancestor componentsFromSelf- Only messages from this componentFromBus- Messages from anywhere in the application
Real-World Example
See TestContainer.tsx for a complete example:
// Structure declares message scope
type Struct = ComponentStruct<
AppMsgStruct,
{
props: { text: string };
children: {
child1: TestChildStruct;
child2: TestChildStruct;
};
msgScope: {
subscribe: AppMsgChannels<'TEST-EVENT'>;
provide: AppMsgChannels<'LOCAL-EVENT'>;
};
}
>;
const def: ComponentDef<Struct> = {
props: { text: '' },
msgBroker: {
subscribe: {
'TEST-EVENT': {
in: {
callback: (msg, c) => {
m.text = msg.payload;
},
componentFilter: ComponentMsgFilter.FromDescendants
}
}
},
provide: {
'LOCAL-EVENT': {
in: {
callback: (msgIn, headers, c) => {
return `Hi ${msgIn.payload} from parent ${c.id}!`;
},
componentFilter: ComponentMsgFilter.FromDescendants
}
}
}
}
};Testing and Mocking
The message bus can be easily mocked for testing:
import { createMsgBus } from '@actdim/msgmesh/core';
// Create mock bus for testing
const mockMsgBus = createMsgBus<AppMsgStruct, ComponentMsgHeaders>({});
// Spy on messages
const sendSpy = jest.spyOn(mockMsgBus, 'send');
// Test component
const component = useComponent(def, { msgBus: mockMsgBus });
// Verify message was sent
expect(sendSpy).toHaveBeenCalledWith({
channel: 'USER-CLICKED',
payload: expect.any(Object)
});Why This Approach is Powerful
1. Type Safety Without Magic Strings
- All channels defined in one place with full typing
- IntelliSense shows available channels
- Compile-time errors for typos
2. Clear Component Boundaries
msgScopedocuments component's external communication- Easy to see what component consumes/provides
- Reduces cognitive load when reading code
3. Loose Coupling
- Components communicate without direct references
- Easy to add/remove components
- Services can be swapped without changing component code
4. Better Project Visibility
- Structure shows children dependencies (direct composition)
- Structure shows message dependencies (loose coupling)
- Complete picture of component's responsibilities
5. Testability
- Message bus can be mocked
- Test components in isolation
- Verify message contracts
6. Flexibility
- Connect any components (not just parent-child)
- Route messages through component hierarchy
- Filter by source with ComponentMsgFilter
- Support both events and request/response patterns
Parent-Child Relationships
Components can access their hierarchy:
// Define parent with children
const parentDef: ComponentDef<ParentStruct> = {
children: {
child1: useChildComponent({ /* params */ }),
child2: useChildComponent({ /* params */ })
},
view: (_, c) => (
<div>
<c.children.child1.View />
<c.children.child2.View />
</div>
)
};
// Access from child component
const parentId = component.getParent();
const ancestors = component.getChainUp();
const descendants = component.getChainDown();Dynamic Content
Not all children need to be full dynstruct components. The children field supports three patterns for embedding dynamic content, ranging from lightweight React wrappers to parameterized component factories.
1. React.FC Wrapper
The simplest approach: declare a child as React.FC in the structure and provide a plain function returning JSX in the definition. This is useful for small inline fragments that need access to the parent's reactive model but don't require their own component structure.
In the structure, the child type is React.FC. In the view, it is accessed with a capitalized name (because it's a function type): <c.children.Summary />.
type Struct = ComponentStruct<AppMsgStruct, {
props: {
counter: number;
};
children: {
summary: React.FC; // standard React functional component
};
}>;
const def: ComponentDef<Struct> = {
props: { counter: 0 },
children: {
// Plain function returning JSX — has access to the parent model
summary: () => {
return <div>Counter: {m.counter}</div>;
},
},
view: (_, c) => (
<div>
{/* Capitalized because it's a function type */}
<c.children.Summary />
</div>
),
};2. DynamicContent Component
When you need typed data and a render function inside a proper dynstruct component, use DynamicContentStruct / useDynamicContent. This gives you a component with a reactive data prop and a render callback, so the content re-renders when the 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>({
// Bind data to a parent property
data: bindProp(() => m, 'text'),
// Render function — can access the component's own model
render: () => {
return <>{c.children.content.model.data}</>;
},
}),
},
view: (_, c) => (
<div>
<c.children.content.View />
</div>
),
};DynamicContentStruct is generic: DynamicContentStruct<TData, TMsgStruct>. The data prop holds typed data (bound to a parent property or passed directly), and render produces the JSX.
3. Factory Function (Parameterized Children)
When you need to create multiple instances of a child component dynamically (e.g. in a loop), declare the child as a factory function in the structure. The function accepts parameters and returns a component structure type.
In the view, factory children are also accessed with a capitalized name and can receive props (including a key):
type Struct = ComponentStruct<AppMsgStruct, {
props: {
counter: number;
text: string;
};
children: {
dynEdit: (props: { value?: string }) => SimpleEditStruct;
};
}>;
const def: ComponentDef<Struct> = {
props: { counter: 0, text: 'bar' },
children: {
// Factory: called each time <c.children.DynEdit /> is rendered
dynEdit: (params) => {
return useSimpleEdit({
value: bindProp(() => m, 'text'),
});
},
},
view: (_, c) => (
<ul>
{Array.from({ length: m.counter }).map((_, i) => (
<li key={i}>
<c.children.DynEdit key={i} />
</li>
))}
</ul>
),
};Summary
| Pattern | Structure type | Access in view | Use case |
|---|---|---|---|
| React.FC wrapper | React.FC | <c.children.Name /> | Small inline fragments with access to parent model |
| DynamicContent | DynamicContentStruct<TData> | <c.children.name.View /> | Typed reactive data with custom render function |
| Factory function | (params) => ChildStruct | <c.children.Name key={...} /> | Multiple dynamic instances, parameterized creation |
Naming convention: Children declared as function types (
React.FC, factory functions) are accessed with a capitalized name in the view (c.children.Summary,c.children.DynEdit). Children declared as component structures use their original name (c.children.content).
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
| Event | Phase | Description |
|---|---|---|
| onInit | preMount | Initialization event. Called after props and children are set up, but before the HTML representation is inserted into the DOM. |
| onLayoutReady | mount | The HTML representation is ready and inserted into the DOM tree, but the frame has not been painted yet. |
| onReady | postMount | The HTML representation has already been rendered and is visible to the user. The component is fully ready for interaction. |
| onLayoutDestroy | preUnmount | The component's HTML representation is about to be removed from the DOM. |
| onDestroy | unmount | The component is destroyed. All resources should be released. The component-scoped msgBus abort signal is triggered, which safely terminates broker subscriptions/providers and pending request(...) operations. |
| onError | — | An error occurred during component operation. Receives the error object and optional info. |
const def: ComponentDef<Struct> = {
events: {
// Initialization (preMount)
onInit: (component) => {
console.log('Component initialized:', component.id);
},
// HTML inserted into DOM, frame not yet painted (mount)
onLayoutReady: (component) => {
console.log('Component layout ready');
},
// HTML rendered and visible (postMount)
onReady: (component) => {
console.log('Component is ready for interaction');
},
// HTML representation about to be removed from DOM
onLayoutDestroy: (component) => {
console.log('Layout will be destroyed');
},
// Component destroyed
onDestroy: (component) => {
console.log('Component destroyed');
},
// Error during component operation
onError: (component, error) => {
console.error('Component error:', error);
}
}
};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) => {
re