Factories#

SignalX provides defineFactory() for creating injectable services with managed subscriptions, disposal, and a real instance lifetime. Factories are ideal for shared logic, API clients, and stateful services.

Basic Factory#

TSX
import { defineFactory } from 'sigx';

const useLogger = defineFactory((ctx) => {
    const logs: string[] = [];

    const log = (message: string) => {
        logs.push(`[${Date.now()}] ${message}`);
        console.log(message);
    };

    const getLogs = () => [...logs];

    ctx.onDeactivated(() => {
        console.log('Logger deactivated');
    });

    return { log, getLogs };
}, 'singleton');

// Usage in component
const MyComponent = component(() => {
    const logger = useLogger();
    
    logger.log('Component mounted');
    
    return () => <div>Check console</div>;
});

The setup function must return an object or a function — returning a primitive throws.

Instance Lifetimes#

The second argument is a Lifetime string literal — 'singleton', 'scoped', or 'transient'. The lifetime is enforced: it determines how many instances exist and when each is disposed.

TSX
import { defineFactory } from 'sigx';

// New instance every call
defineFactory(setup, 'transient');

// Nearest defineProvide provider in the tree, falling back to the app instance
defineFactory(setup, 'scoped');

// One instance per app
defineFactory(setup, 'singleton');

Lifetime Comparison#

LifetimeInstancesSemantics
'singleton'One per appCreated on first resolution per AppContext, disposed on app.unmount(). Outside any app context, one instance per JS realm
'scoped'Per providerThe nearest instance provided via defineProvide in the component tree; falls back to the app-context instance (and outside any app/component context to the per-realm instance)
'transient'Per callA new instance each call, disposed with the calling component — or owned by the caller via dispose()

Disposal#

Every factory instance gets a dispose() method attached — non-enumerable (it never shows up when iterating or serializing the instance) and idempotent (safe to call twice). If the setup returns its own dispose, the generated one delegates to it.

Disposal runs the onDeactivated callbacks and the ctx.subscriptions cleanups:

  • 'singleton' instances are disposed when their app is unmounted (app.unmount()).
  • 'scoped' instances are disposed when their provider component unmounts.
  • 'transient' instances are disposed with the calling component; created outside a component, the caller owns disposal and calls dispose() manually.
TSX
const transient = useThing();   // inside a component: auto-disposed on unmount

const standalone = useThing();  // outside a component: you own it
standalone.dispose();           // idempotent — safe to call again

Factory Context#

The factory setup function receives a context with lifecycle hooks:

TSX
const useService = defineFactory((ctx) => {
    // Called when the factory instance is disposed
    ctx.onDeactivated(() => {
        console.log('Cleaning up...');
    });

    // Subscription helper for auto-cleanup
    ctx.subscriptions.add(() => {
        // This runs on disposal
    });

    // Override default dispose behavior
    ctx.overrideDispose((defaultDispose) => {
        // Custom cleanup logic
        defaultDispose();
    });

    return { /* service API */ };
}, 'singleton');

API Client Example#

TSX
import { defineFactory, signal } from 'sigx';

interface User {
    id: number;
    name: string;
    email: string;
}

const useUserApi = defineFactory((ctx) => {
    const cache = signal(new Map<number, User>());
    const loading = signal({ value: false });
    
    const fetchUser = async (id: number): Promise<User> => {
        // Check cache first
        const cached = cache.get(id);
        if (cached) return cached;
        
        loading.value = true;
        try {
            const response = await fetch(`/api/users/${id}`);
            const user = await response.json();
            cache.set(id, user);
            return user;
        } finally {
            loading.value = false;
        }
    };
    
    const invalidateCache = () => {
        cache.$set(new Map());
    };
    
    ctx.onDeactivated(() => {
        invalidateCache();
    });
    
    return {
        fetchUser,
        invalidateCache,
        isLoading: () => loading.value
    };
}, 'singleton');

// Usage
const UserProfile = component(({ props }) => {
    const userApi = useUserApi();
    const state = signal({ user: null as User | null });
    
    onMounted(async () => {
        state.user = await userApi.fetchUser(props.userId);
    });
    
    return () => (
        <div>
            {userApi.isLoading() && <Spinner />}
            {state.user && <h1>{state.user.name}</h1>}
        </div>
    );
});

Factories with Parameters#

Factories can accept parameters for dynamic creation. A parameterized factory returns a FactoryFunction creator (typed overloads up to 5 parameters):

TSX
const useRepository = defineFactory((ctx, entityName: string) => {
    const baseUrl = `/api/${entityName}`;
    
    const getAll = () => fetch(baseUrl).then(r => r.json());
    const getById = (id: number) => fetch(`${baseUrl}/${id}`).then(r => r.json());
    const create = (data: any) => fetch(baseUrl, {
        method: 'POST',
        body: JSON.stringify(data)
    });
    
    return { getAll, getById, create };
}, 'transient');

// Usage — each call creates a fresh instance (transient)
const usersRepo = useRepository('users');
const postsRepo = useRepository('posts');

For non-transient parameterized factories, arguments are honored at first creation only — later calls resolve the existing shared instance, whatever arguments they pass. If different argument sets must yield different instances, use 'transient' (or provide per-subtree instances with defineProvide).

Subscription Management#

Use the built-in SubscriptionHandler for managing subscriptions:

TSX
const useWebSocket = defineFactory((ctx) => {
    let ws: WebSocket | null = null;
    
    const connect = (url: string) => {
        ws = new WebSocket(url);
        
        // Auto-cleanup on factory disposal
        ctx.subscriptions.add(() => {
            ws?.close();
        });
    };
    
    const send = (data: any) => {
        ws?.send(JSON.stringify(data));
    };
    
    return { connect, send };
}, 'singleton');

Combining with Dependency Injection#

Factory use-functions satisfy Providable<T>, so they work with defineProvide / app.defineProvide exactly like defineInjectable tokens — including parameterized creators. Providing a 'scoped' factory gives a subtree its own instance; the provider component owns it and disposes it on unmount.

TSX
import { component, defineFactory, defineProvide, signal } from 'sigx';

// Define the factory
const useAuthService = defineFactory((ctx) => {
    const state = signal({ 
        user: null as User | null,
        token: null as string | null
    });
    
    const login = async (email: string, password: string) => {
        const response = await fetch('/auth/login', {
            method: 'POST',
            body: JSON.stringify({ email, password })
        });
        const data = await response.json();
        state.user = data.user;
        state.token = data.token;
    };
    
    const logout = () => {
        state.user = null;
        state.token = null;
    };
    
    return {
        get user() { return state.user; },
        get isAuthenticated() { return !!state.token; },
        login,
        logout
    };
}, 'scoped');

// Provide a fresh instance for a subtree (disposed when the provider unmounts)
const AccountArea = component(({ slots }) => {
    defineProvide(useAuthService);
    return () => <>{slots.default?.()}</>;
});

// Usage in components
const LoginForm = component(({ signal }) => {
    const auth = useAuthService();   // nearest provided instance
    const form = signal({ email: '', password: '' });
    
    const handleSubmit = async () => {
        await auth.login(form.email, form.password);
    };
    
    return () => (
        <form onSubmit={handleSubmit}>
            <input 
                type="email" 
                value={form.email}
                onInput={(e) => form.email = e.target.value}
            />
            <input 
                type="password"
                value={form.password}
                onInput={(e) => form.password = e.target.value}
            />
            <button type="submit">Login</button>
        </form>
    );
});

const UserStatus = component(() => {
    const auth = useAuthService();
    
    return () => (
        <div>
            {auth.isAuthenticated 
                ? <span>Welcome, {auth.user?.name}</span>
                : <span>Not logged in</span>
            }
        </div>
    );
});

API Reference#

defineFactory(setup, lifetime, typeIdentifier?)#

Create an injectable factory function.

ParameterTypeDescription
setup(ctx: SetupFactoryContext, ...args) => TFactory setup function. Must return an object or function
lifetimeLifetime'singleton', 'scoped', or 'transient'
typeIdentifierguidOptional unique identifier

Returns: An InjectableFunction<T & { dispose: () => void }> for parameterless setups, or a FactoryFunction creator for parameterized setups. Both work with defineProvide / app.defineProvide.

SetupFactoryContext#

Context passed to factory setup functions.

PropertyTypeDescription
onDeactivated(fn: () => void) => voidRegister cleanup callback
subscriptionsSubscriptionHandlerHelper for managing subscriptions
overrideDispose(fn: (dispose: () => void) => void) => voidCustom disposal logic

Lifetime#

String-literal union for factory instance lifetimes.

TSX
type Lifetime = 'singleton' | 'scoped' | 'transient';
ValueDescription
'singleton'One instance per app (AppContext), disposed on app.unmount(); per-realm fallback outside apps
'scoped'Nearest defineProvide provider, falling back to the app instance
'transient'New instance every invocation, disposed with the calling component or via dispose()

SubscriptionHandler#

Helper class for managing cleanup subscriptions.

TSX
ctx.subscriptions.add(() => cleanup());
ctx.subscriptions.unsubscribe(); // Call all cleanup functions

Best Practices#

  1. Use 'singleton' for app-wide shared state:

    TSX
    const useStore = defineFactory(setup, 'singleton');
  2. Clean up resources:

    TSX
    ctx.onDeactivated(() => {
        socket.close();
        subscription.unsubscribe();
    });
  3. Keep factories focused:

    TSX
    // Good: Single responsibility
    const useUserApi = defineFactory(/* user API only */);
    const usePostApi = defineFactory(/* post API only */);
    
    // Avoid: Kitchen sink
    const useApi = defineFactory(/* everything */);
  4. Expose reactive getters:

    TSX
    return {
        get user() { return state.user; },  // Reactive
        getUser: () => state.user           // Also reactive
    };
  5. Dispose transient instances you create outside components:

    TSX
    const job = useJob();
    try { await job.run(); } finally { job.dispose(); }

Next Steps#