App & Plugins
SignalX provides a powerful application model with plugin support, dependency injection, and global lifecycle hooks. The defineApp() function is the entry point for configuring and bootstrapping your application.
Mounting Your Application
SignalX provides two ways to render your application:
| Method | Plugins | DI | Lifecycle Hooks | Unmount |
|---|---|---|---|---|
render() | ❌ | ❌ | ❌ | ❌ |
defineApp() | ✅ | ✅ | ✅ | ✅ |
render() - Low-Level Rendering
The simplest way to render - directly mounts a JSX element to a container:
import { render } from 'sigx';
// Using CSS selector
render(<App />, '#app');
// Using element reference
render(<App />, document.getElementById('app')!);
Use render() for simple cases, prototypes, or when you don't need plugins or dependency injection.
defineApp() - Full Application Model
The recommended approach for production applications. Provides plugins, dependency injection, lifecycle hooks, and proper unmounting:
import { defineApp } from 'sigx';
import App from './App';
const app = defineApp(<App />);
app.mount(document.getElementById('app')!);
The app instance provides a chainable API for configuration:
defineApp(<App />)
.use(routerPlugin)
.use(devToolsPlugin, { debug: true })
.provide(ConfigToken, myConfig)
.mount(document.getElementById('app')!);
App Configuration
Register the global error handler with app.onError, and configure warnings and performance via app.config:
const app = defineApp(<App />);
// Global error handler — the app-wide catch-all for render/re-render throws,
// DOM event-handler throws, and unhandled data errors no errorScope took.
app.onError((err, instance, info) => {
console.error(`Error in ${instance?.name || 'unknown'}:`, err);
// Return true to suppress the error from propagating
return false;
});
// Warning handler (dev mode)
app.config.warnHandler = (msg, instance, trace) => {
console.warn(`[SignalX Warning] ${msg}`);
};
// Enable performance tracking
app.config.performance = true;
app.onError is the app-level safety net. To keep part of the UI alive with a local fallback instead, scope a subtree with errorScope — see Error handling.
Plugins
Plugins extend the application with additional functionality. They can add global providers, register lifecycle hooks, and configure the app.
Object-Style Plugin
import { type Plugin } from 'sigx';
const myPlugin: Plugin<{ debug?: boolean }> = {
name: 'my-plugin',
install(app, options) {
// Provide values to all components
app.provide('myService', new MyService());
// Register lifecycle hooks
app.hook({
onComponentMounted: (instance) => {
if (options?.debug) {
console.log('Mounted:', instance.name);
}
}
});
// Configure error handling
app.onError((err, instance, info) => {
reportError(err, instance?.name, info);
return true; // Error handled
});
}
};
// Install the plugin
app.use(myPlugin, { debug: true });
The plugin surface: app._context
The high-level methods above — app.provide, app.hook, app.onError — cover
most plugins. A plugin that integrates at a lower level (providing through a
framework seam, or registering teardown) reaches for app._context, the
app's context object and the documented plugin-author surface:
const myPlugin: Plugin = {
name: 'my-plugin',
install(app) {
// Hand the context to a seam's provide-helper…
provideMyEngine(app._context, createEngine());
// …or use its maps directly.
app._context.provides.set(MY_TOKEN, value);
app._context.disposables.push(() => cleanup());
},
};
The leading underscore marks it as an advanced surface — it's the seam packs like
@sigx/cache build on — but it's a supported one:
provides and disposables are the contract. (Other underscore-prefixed fields,
like _isMounted and _container, remain internal.)
Function-Style Plugin
For simpler plugins, you can use a function:
import { type PluginInstallFn } from 'sigx';
const simplePlugin: PluginInstallFn = (app) => {
app.provide('logger', createLogger());
};
app.use(simplePlugin);
Plugin Example: DevTools
Here's a complete example of a development tools plugin:
const devToolsPlugin: Plugin<{ logMounts?: boolean }> = {
name: 'devtools',
install(app, options) {
if (options?.logMounts) {
app.hook({
onComponentMounted: (instance) => {
console.log(`[DevTools] Mounted: ${instance.name || 'anonymous'}`);
},
onComponentUnmounted: (instance) => {
console.log(`[DevTools] Unmounted: ${instance.name || 'anonymous'}`);
}
});
}
// Global error handling
app.onError((err, instance, info) => {
console.error(`[DevTools] Error in ${instance?.name}:`, err, info);
return false; // Don't suppress - let it propagate
});
}
};
app.use(devToolsPlugin, { logMounts: true });
Plugins are Installed Once
SignalX automatically prevents duplicate plugin installation:
app.use(myPlugin); // Installed
app.use(myPlugin); // Skipped with warning
Dependency Injection
SignalX provides a powerful dependency injection system using provide and inject.
App-Level Provides
Values provided at the app level are available to all components:
// Simple value
app.provide('apiUrl', 'https://api.example.com');
// Object
app.provide('config', {
version: '1.0.0',
debug: true
});
Using defineInjectable
For type-safe dependency injection with automatic fallback to singletons:
import { defineInjectable, inject } from 'sigx';
// Define an injectable token with a factory function
const useApiConfig = defineInjectable(() => ({
baseUrl: 'https://api.example.com',
timeout: 5000
}));
// Provide a custom value at app level
app.provide(useApiConfig, {
baseUrl: 'https://custom.api.com',
timeout: 10000
});
// In any component - just call the function!
const MyComponent = component(({ }) => {
const config = useApiConfig(); // Returns provided or default value
return () => <div>API: {config.baseUrl}</div>;
});
Required injectables
defineInjectable has a second form: pass a name string instead of a factory
and you get a required injectable — one with no singleton fallback.
// Factory form — falls back to a module-global singleton if nobody provides it.
const useLogger = defineInjectable(() => new Logger());
// Required form — declared by name, no fallback.
const useRouter = defineInjectable<Router>('Router');
Using a required injectable that was never provided throws
SIGX202, naming the injectable — instead of silently
handing back a shared global. That's the point of the form: a per-request
dependency like a router or an auth session must be provided per app, and a global
fallback would leak state between requests on a server. Provide it before anything
resolves it:
const app = createApp(App);
app.defineProvide(useRouter, () => createRouter(url));
app.mount('#app');
Two related refinements make provides more predictable:
- Providing
undefinednow shadows the fallback. An explicitdefineProvide(useThing, () => undefined)means "the value isundefinedhere", not "fall through to the default". - App provides are looked up live.
app.defineProvide(...)called afterapp.mount()is visible to components mounted afterwards — useful for plugins that provide lazily.
In development, a factory-form injectable that falls back to its module-global singleton on the server while an app exists logs a one-time warning — that singleton is shared across every SSR request in the process. The fix is to provide it per app, or declare it required. (The warning is server-only and stripped from production.)
Component-Level Provides
Components can also provide values to their descendants:
import { provide, inject } from 'sigx';
const Parent = component(({ }) => {
// Provide to all descendants
provide('theme', { primary: '#3498db' });
return () => <Child />;
});
const Child = component(({ }) => {
// Inject from parent or app
const theme = inject('theme');
return () => (
<div style={`color: ${theme?.primary}`}>
Themed content
</div>
);
});
defineProvide
Create and provide an instance in one step:
import { defineInjectable, defineProvide } from 'sigx';
const useAuthService = defineInjectable(() => new AuthService());
const App = component(({ }) => {
// Creates instance AND provides it to descendants
const auth = defineProvide(useAuthService);
return () => <LoginForm />;
});
Resolving DI Outside Component Setup
Use-functions from defineInjectable / defineFactory (and inject) resolve against the current DI context, which is normally set up only while a component's setup runs. Code that runs outside setup — router navigation guards, socket message handlers, entry-scope bootstrap — has no current context, so those calls silently fall back to a realm-level instance instead of the app's.
app.runWithContext(fn) runs fn with the app's context current, so use-functions resolve to the same instances your components receive:
const app = defineApp(<App />);
router.beforeEach((to) => {
// Without runWithContext, useAuth() here resolves a different instance
app.runWithContext(() => {
const auth = useAuthService();
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return '/login';
}
});
});
socket.on('message', (msg) => {
app.runWithContext(() => {
useMessageStore().receive(msg);
});
});
The context applies to the synchronous portion of fn only — it's restored in a finally, so it never leaks past the first await. Nested calls restore the previous context, and plugins can capture the app they receive in install() to wrap their own callbacks.
Because the context is synchronous, an async callback resolves its later dependencies against the wrong context — everything after the first await runs with the context already restored. In development, runWithContext warns once per app when its callback returns a promise:
// ⚠️ resolves useAuthService() correctly, but useMessageStore() after the await does not
app.runWithContext(async () => {
const auth = useAuthService(); // ✅ synchronous — correct context
await auth.refresh();
useMessageStore().receive(msg); // ❌ context already restored
});
Re-enter with another runWithContext after the await to resolve more dependencies. runWithContext returns its callback's value, so capture what you resolve synchronously:
const auth = app.runWithContext(() => useAuthService()); // resolved in-context
await auth.refresh();
app.runWithContext(() => { useMessageStore().receive(msg); });
Lifecycle Hooks
Register global hooks to observe all components in your application:
app.hook({
// Called after setup() completes
onComponentCreated: (instance) => {
console.log('Created:', instance.name);
},
// Called after component is mounted
onComponentMounted: (instance) => {
console.log('Mounted:', instance.name);
},
// Called after component re-renders
onComponentUpdated: (instance) => {
console.log('Updated:', instance.name);
},
// Called before component is unmounted
onComponentUnmounted: (instance) => {
console.log('Unmounted:', instance.name);
},
// Called when an error occurs
// Return true to suppress the error
onComponentError: (err, instance, info) => {
console.error('Error:', err);
return false;
}
});
Component Instance
The instance passed to hooks contains:
| Property | Type | Description |
|---|---|---|
name | string? | Component name (if defined) |
ctx | ComponentSetupContext | The component's setup context |
vnode | VNode | The component's virtual node |
Mounting
Basic Mount
app.mount(document.getElementById('app')!);
Platform-Specific Mount
app.mount(...) automatically picks the correct mount function based on the runtime you imported (sigx itself wires up DOM mounting):
import { defineApp } from 'sigx';
import { App } from './App';
defineApp(<App />).mount(document.getElementById('app')!);
Additional renderers (e.g. server, terminal) follow the same pattern — import the runtime package and call app.mount(...) with the target it expects.
Unmounting
Clean up resources when the app is no longer needed:
app.unmount();
Router Plugin
The @sigx/router package provides a router that integrates with the app system:
import { defineApp } from 'sigx';
import { createRouter, createWebHistory } from '@sigx/router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/blog/:slug', component: BlogPost }
]
});
defineApp(<App />)
.use(router) // Router implements Plugin interface
.mount(document.getElementById('app')!);
Or use createRouterPlugin for more configuration:
import { createRouterPlugin } from '@sigx/router';
const routerPlugin = createRouterPlugin({
history: createWebHistory(),
routes: [/* ... */],
base: '/my-app'
});
app.use(routerPlugin);
// Access router instance
const router = routerPlugin.router;
Complete Example
Here's a complete application setup:
import { defineApp, defineInjectable, type Plugin, component } from 'sigx';
import { createRouter, createWebHistory, RouterView } from '@sigx/router';
// Define services
const useApi = defineInjectable(() => ({
fetch: async (url: string) => {
const res = await fetch(url);
return res.json();
}
}));
// Create router
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomePage },
{ path: '/users/:id', component: UserPage }
]
});
// Analytics plugin
const analyticsPlugin: Plugin = {
name: 'analytics',
install(app) {
app.hook({
onComponentMounted: (instance) => {
trackPageView(instance.name);
}
});
}
};
// App component
const App = component(({ }) => {
return () => (
<div>
<nav>...</nav>
<RouterView />
</div>
);
});
// Bootstrap
defineApp(<App />)
.use(router)
.use(analyticsPlugin)
.provide(useApi, customApiImplementation)
.hook({
onComponentError: (err) => {
Sentry.captureException(err);
return false;
}
})
.mount(document.getElementById('app')!);
API Reference
defineApp(rootComponent)
Creates an app instance.
Returns: App<TContainer>
App Methods
| Method | Description |
|---|---|
use(plugin, options?) | Install a plugin |
provide(token, value) | Provide a value to all components |
hook(hooks) | Register lifecycle hooks |
runWithContext(fn) | Run fn with the app's DI context current (for use-functions called outside setup) |
mount(container, mountFn?) | Mount the app |
unmount() | Unmount and cleanup |
config | App configuration object |
Injection Functions
| Function | Description |
|---|---|
defineInjectable(factory) | Create an injectable with a singleton fallback |
defineInjectable(name) | Create a required injectable — no fallback; throws SIGX202 if used unprovided |
inject(token) | Get a provided value |
provide(token, value) | Provide a value (in component) |
defineProvide(useFn) | Create and provide in one step |
Types
interface Plugin<Options = any> {
name?: string;
install(app: App, options?: Options): void;
}
type PluginInstallFn<Options = any> = (app: App, options?: Options) => void;
interface AppLifecycleHooks {
onComponentCreated?(instance: ComponentInstance): void;
onComponentMounted?(instance: ComponentInstance): void;
onComponentUpdated?(instance: ComponentInstance): void;
onComponentUnmounted?(instance: ComponentInstance): void;
onComponentError?(err: Error, instance: ComponentInstance, info: string): boolean | void;
}
interface AppConfig {
onError?(err: Error, instance: ComponentInstance | null, info: string): boolean | void;
warnHandler?(msg: string, instance: ComponentInstance | null, trace: string): void;
performance?: boolean;
}
