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:

MethodPluginsDILifecycle HooksUnmount
render()
defineApp()

render() - Low-Level Rendering#

The simplest way to render - directly mounts a JSX element to a container:

TSX
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:

TSX
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:

TSX
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:

TSX
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#

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
// 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:

TSX
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.

TSX
// 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:

TypeScript
const app = createApp(App);
app.defineProvide(useRouter, () => createRouter(url));
app.mount('#app');

Two related refinements make provides more predictable:

  • Providing undefined now shadows the fallback. An explicit defineProvide(useThing, () => undefined) means "the value is undefined here", not "fall through to the default".
  • App provides are looked up live. app.defineProvide(...) called after app.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:

TSX
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:

TSX
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:

TSX
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:

TSX
// ⚠️ 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:

TSX
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:

TSX
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:

PropertyTypeDescription
namestring?Component name (if defined)
ctxComponentSetupContextThe component's setup context
vnodeVNodeThe component's virtual node

Mounting#

Basic Mount#

TSX
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):

TSX
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:

TSX
app.unmount();

Router Plugin#

The @sigx/router package provides a router that integrates with the app system:

TSX
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:

TSX
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:

TSX
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#

MethodDescription
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
configApp configuration object

Injection Functions#

FunctionDescription
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#

TSX
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;
}