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
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.
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
| Lifetime | Instances | Semantics |
|---|---|---|
'singleton' | One per app | Created on first resolution per AppContext, disposed on app.unmount(). Outside any app context, one instance per JS realm |
'scoped' | Per provider | The 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 call | A 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 callsdispose()manually.
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:
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
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):
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:
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.
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.
| Parameter | Type | Description |
|---|---|---|
setup | (ctx: SetupFactoryContext, ...args) => T | Factory setup function. Must return an object or function |
lifetime | Lifetime | 'singleton', 'scoped', or 'transient' |
typeIdentifier | guid | Optional 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.
| Property | Type | Description |
|---|---|---|
onDeactivated | (fn: () => void) => void | Register cleanup callback |
subscriptions | SubscriptionHandler | Helper for managing subscriptions |
overrideDispose | (fn: (dispose: () => void) => void) => void | Custom disposal logic |
Lifetime
String-literal union for factory instance lifetimes.
type Lifetime = 'singleton' | 'scoped' | 'transient';
| Value | Description |
|---|---|
'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.
ctx.subscriptions.add(() => cleanup());
ctx.subscriptions.unsubscribe(); // Call all cleanup functions
Best Practices
-
Use
'singleton'for app-wide shared state:TSXconst useStore = defineFactory(setup, 'singleton'); -
Clean up resources:
TSXctx.onDeactivated(() => { socket.close(); subscription.unsubscribe(); }); -
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 */); -
Expose reactive getters:
TSXreturn { get user() { return state.user; }, // Reactive getUser: () => state.user // Also reactive }; -
Dispose transient instances you create outside components:
TSXconst job = useJob(); try { await job.run(); } finally { job.dispose(); }
Next Steps
- Messaging - Pub/sub communication
- Dependency Injection - DI with provide/inject
- Components - Component patterns
