craft
Creates a type-safe, composable state management store with Angular dependency injection.
Import
import { craft } from '@ng-angular-stack/craft';Introduction
craft is the core function for building craft stores. It enables:
- Type-safe composition: Chain multiple craft utilities (craftState, craftQuery, craftMutation, etc.)
- Dependency injection: Choose between root-level or feature-level provision
- Smart naming: Auto-generates injection and composition functions based on store name
- Store composition: Connect stores together via craftX functions with input/method binding
- Standalone methods: Export methods that can be called outside injection context
- Contract enforcement: Optional type contracts for store implementation
- Error detection: Type-level errors for configuration mistakes
Naming Convention
Based on the options.name parameter, craft automatically generates:
- Injection function:
inject{Name}Craft()- Injects the store instance - Composition function:
craft{Name}(config?)- Composes this store into another - Injection token:
{Name}Craft- Angular injection token for the store - Metadata:
_{UPPERCASE_NAME}_META_STORE_CONTEXT- Type metadata for the store
Naming Examples
| Name | Injection Function | Composition Function |
|---|---|---|
'counter' | injectCounterCraft() | craftCounter() |
'userAuth' | injectUserAuthCraft() | craftUserAuth() |
'dataPagination' | injectDataPaginationCraft() | craftDataPagination() |
ProvidedIn Strategy
The providedIn option controls how Angular provides the store:
'root' (Global singleton)
- Single instance shared across the entire application
- Survives route changes and component destruction
- Ideal for: global state, authentication, app configuration
- When composed into other stores, the same instance is reused
'feature' (Scoped instances)
- New instance created per injection context
- Does not survive outside its injection scope
- Ideal for: component-specific state, route-scoped data, isolated features
- When composed into other stores, each host gets its own instance
Store Composition
Use the generated craft{Name}() function to compose one store into another:
- Access another store's state, methods, and capabilities
- Bind inputs from host store to composed store
- Connect host methods to composed store sources
- Unbound inputs/methods are automatically propagated to the host
- Type-safe with error detection for invalid bindings
Injection with Input/Method Binding
The inject{Name}Craft() function accepts a configuration object to:
- Bind inputs: Pass signals or values to store inputs
- Connect methods to sources: Replace methods with source emissions
- Enable dynamic configuration at injection time
- Reduce boilerplate when the store is used
Error Detection
The type system provides compile-time errors for:
errorMethodMsg: When connecting methods that don't exist in the composed storeerrorInputsMsg: When binding inputs that aren't defined in the composed store- Contract violations: When the store doesn't satisfy its
implementscontract - These errors appear as properties on the configuration object with descriptive messages
Standalone Methods
Craft utilities (craftSources, craftMutations, etc.) can expose standalone methods:
- These are returned directly from
craft()and can be destructured - Can be called outside Angular's injection context
- Useful for event handlers, callbacks, external integrations
- Examples:
setReset(),setPaginationQueryParams()
Signature
function craft<Context, StoreConfig, ProvidedIn, Name, ToImplementContract>(
options: {
name: string;
providedIn: 'root' | 'feature';
implements?: Contract<ToImplementContract>;
},
...factories: CraftFactory[]
): CraftResult;Parameters
options
Store configuration object:
name: Store name (camelCase recommended). Used to generate function names.providedIn: Angular injection scope.'root'for global singleton,'feature'for scoped instances.implements: Optional contract type. Usecontract<YourType>()to enforce implementation.
factories
Variable number of craft utility functions (craftState, craftQuery, craftSources, etc.). Each factory receives the accumulated context from previous factories.
Return Value
An object containing:
inject{Name}Craft: Function to inject the store with optional input/method bindingcraft{Name}: Function to compose this store into another store{Name}Craft: Angular injection token for manual injection_{UPPERCASE_NAME}_META_STORE_CONTEXT: Type metadata (for advanced use cases)- Standalone methods: Any standalone outputs from craft utilities (e.g.,
setReset())
Examples
Basic counter store with sources and state
const { injectCounterCraft, setIncrement, setDecrement, setReset } = craft(
{ name: 'counter', providedIn: 'root' },
craftSources({
increment: source<void>(),
decrement: source<void>(),
reset: source<void>(),
}),
craftState('count', ({ increment, decrement, reset }) =>
state(0, ({ state, set }) => ({
increment: afterRecomputation(increment, () => set(state() + 1)),
decrement: afterRecomputation(decrement, () => set(state() - 1)),
reset: afterRecomputation(reset, () => set(0)),
})),
),
);
// In a component
const store = injectCounterCraft();
console.log(store.count()); // 0
store.setIncrement(); // count: 1
// wait another cycle
store.setDecrement(); // count: 0
// wait another cycle
store.setReset(); // count: 0
// Standalone methods work outside injection context
document.addEventListener('click', () => {
setIncrement(); // Works!
});Store with inputs for dynamic configuration
const { injectTimerCraft } = craft(
{ name: 'timer', providedIn: 'feature' }, // Feature-scoped
craftInputs({
initialValue: undefined as number | undefined,
step: undefined as number | undefined,
}),
craftSources({
tick: source<void>(),
}),
craftState('time', ({ initialValue, step, tick }) =>
state(
linkedSignal(() => initialValue() ?? 0),
({ state, set }) => ({
tick: afterRecomputation(tick, () => {
set(state() + (step() ?? 1));
}),
}),
),
),
);
// Inject with input binding
const timer1 = injectTimerCraft({
inputs: {
initialValue: signal(100),
step: signal(5),
},
});
const timer2 = injectTimerCraft({
inputs: {
initialValue: signal(0),
step: signal(1),
},
});
// Each instance is independent (feature-scoped)
timer1.time(); // 100
timer2.time(); // 0Store composition with root-level singleton
// Global authentication store
const { craftAuth } = craft(
{ name: 'auth', providedIn: 'root' }, // Global singleton
craftState('user', () =>
state({ id: null, name: '' }, ({ set }) => ({
login: (user: { id: number; name: string }) => set(user),
logout: () => set({ id: null, name: '' }),
})),
),
);
// Dashboard store uses auth
const { injectDashboardCraft } = craft(
{ name: 'dashboard', providedIn: 'root' },
craftAuth(), // No config needed, uses shared instance
craftState('dashboardData', ({ user }) =>
state(
linkedSignal(() => `Dashboard for ${user().name}`),
() => ({}),
),
),
);
// Profile store also uses auth
const { injectProfileCraft } = craft(
{ name: 'profile', providedIn: 'root' },
craftAuth(), // Same auth instance
craftState('profileData', ({ user }) =>
state(
linkedSignal(() => `Profile: ${user().name}`),
() => ({}),
),
),
);
// Both stores share the same auth instance
const dashboard = injectDashboardCraft();
const profile = injectProfileCraft();
dashboard.userLogin({ id: 1, name: 'Alice' });
console.log(dashboard.user().name); // 'Alice'
console.log(profile.user().name); // 'Alice' (same instance!)Store composition with feature-level scoping
// Reusable pagination store
const { craftPagination } = craft(
{ name: 'pagination', providedIn: 'feature' }, // Scoped instance
craftInputs({
pageSize: undefined as number | undefined,
}),
craftState('page', ({ pageSize }) =>
state(
{ current: 1, size: linkedSignal(() => pageSize() ?? 10) },
({ state, set }) => ({
nextPage: () => set({ ...state(), current: state().current + 1 }),
prevPage: () => set({ ...state(), current: state().current - 1 }),
}),
),
),
);
// Users table with pagination
const { injectUsersTableCraft } = craft(
{ name: 'usersTable', providedIn: 'root' },
craftPagination(() => ({
inputs: { pageSize: signal(20) },
})),
craftState('users', () => state([], () => ({}))),
);
// Products table with pagination
const { injectProductsTableCraft } = craft(
{ name: 'productsTable', providedIn: 'root' },
craftPagination(() => ({
inputs: { pageSize: signal(50) },
})),
craftState('products', () => state([], () => ({}))),
);
// Each table has its own pagination instance
const usersTable = injectUsersTableCraft();
const productsTable = injectProductsTableCraft();
usersTable.page().size; // 20
productsTable.page().size; // 50
usersTable.pageNextPage();
usersTable.page().current; // 2
productsTable.page().current; // 1 (independent!)Binding methods to sources during composition
const { craftLogger } = craft(
{ name: 'logger', providedIn: 'root' },
craftSources({
log: source<string>(),
}),
craftState('logs', ({ log }) =>
state([] as string[], ({ state, set }) => ({
addLog: afterRecomputation(log, (message) => {
set([...state(), message]);
}),
clear: () => set([]),
})),
),
);
const { injectAppCraft } = craft(
{ name: 'app', providedIn: 'root' },
craftSources({
appError: source<string>(),
}),
craftState('errorCount', ({ appError }) =>
state(0, ({ state, set }) => ({
onError: afterRecomputation(appError, () => set(state() + 1)),
})),
),
// Connect appError source to logger's clear method
craftLogger(({ appError }) => ({
methods: {
logsClear: appError, // When appError emits, call logsClear
},
})),
);
const app = injectAppCraft();
app.setLog('User logged in');
app.logs().length; // 1
app.setAppError('Something went wrong');
// -> errorCount incremented
// -> logs cleared (logsClear called via appError)
app.logs().length; // 0
app.errorCount(); // 1Input/method binding with EXTERNALLY_PROVIDED
const { craftTheme } = craft(
{ name: 'theme', providedIn: 'root' },
craftInputs({
initialTheme: undefined as 'light' | 'dark' | undefined,
}),
craftState('theme', ({ initialTheme }) =>
state(
linkedSignal(() => initialTheme() ?? 'light'),
({ set }) => ({
setTheme: (theme: 'light' | 'dark') => set(theme),
}),
),
),
);
// Host store binds the input
const { injectAppCraft } = craft(
{ name: 'app', providedIn: 'root' },
craftState('appTheme', () => state('dark' as 'light' | 'dark', () => ({}))),
craftTheme(({ appTheme }) => ({
inputs: {
initialTheme: appTheme, // Bind input
},
})),
);
// Another host provides the input externally
const { craftOtherApp } = craft(
{ name: 'otherApp', providedIn: 'root' },
// Input is not bound here, marked as EXTERNALLY_PROVIDED
craftTheme(() => ({
inputs: {
initialTheme: 'EXTERNALLY_PROVIDED',
},
})),
);
// When composing otherApp, initialTheme must be provided
const { injectFinalCraft } = craft(
{ name: 'final', providedIn: 'root' },
craftOtherApp(() => ({
inputs: {
initialTheme: signal('light'), // Must provide this
},
})),
);Query and mutation with automatic reactivity
const { injectTodosCraft } = craft(
{ name: 'todos', providedIn: 'root' },
craftQuery('todoList', () =>
query({
params: () => ({}),
loader: async () => {
const response = await fetch('/api/todos');
return response.json();
},
}),
),
craftMutations(() => ({
addTodo: mutation({
method: (text: string) => ({ text }),
loader: async ({ params }) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(params),
});
return response.json();
},
onSuccess: ({ helpers }) => {
// Invalidate query on success
helpers.invalidateQueries(['todoList']);
},
}),
})),
);
const todos = injectTodosCraft();
todos.mutateAddTodo('Buy milk'); // Mutation
// -> todoList query auto-refreshes after mutation succeedsQuery params for URL synchronization
const { injectSearchCraft, setSearchQueryParams } = craft(
{ name: 'search', providedIn: 'root' },
craftQueryParam('search', () =>
queryParam({
state: {
query: {
fallbackValue: '',
parse: (value) => value,
serialize: (value) => value,
},
page: {
fallbackValue: 1,
parse: (value) => parseInt(value, 10),
serialize: (value) => String(value),
},
},
}),
),
craftQuery('results', ({ searchQuery, searchPage }) =>
query({
params: linkedSignal(() => ({
q: searchQuery(),
page: searchPage(),
})),
loader: async ({ params }) => {
const response = await fetch(
`/api/search?q=${params.q}&page=${params.page}`,
);
return response.json();
},
}),
),
);
const search = injectSearchCraft();
// Change query params (syncs to URL)
setSearchQueryParams({ query: 'angular', page: 2 });
// -> URL updates to ?query=angular&page=2
// -> results query auto-refreshes with new paramsAsync methods for side effects
const { injectNotificationsCraft } = craft(
{ name: 'notifications', providedIn: 'root' },
craftState('messages', () =>
state([] as string[], ({ state, set }) => ({
addMessage: (msg: string) => set([...state(), msg]),
clear: () => set([]),
})),
),
craftAsyncMethods(() => ({
showNotification: asyncMethod({
method: (message: string, duration: number) => ({ message, duration }),
loader: async ({ params, helpers }) => {
helpers.methods.messagesAddMessage(params.message);
await new Promise((resolve) => setTimeout(resolve, params.duration));
// Auto-remove after duration
const current = helpers.props.messages();
helpers.methods.messagesClear();
return 'done';
},
}),
})),
);
const notifications = injectNotificationsCraft();
notifications.showNotificationExecute('Hello!', 3000);
// Message appears, then disappears after 3 secondsContract enforcement for type safety
type CounterContract = {
count: Signal<number>;
increment: () => void;
decrement: () => void;
};
// This store satisfies the contract
const { injectCounterCraft } = craft(
{
name: 'counter',
providedIn: 'root',
implements: contract<CounterContract>(),
},
craftState('count', () =>
state(0, ({ state, set }) => ({
increment: () => set(state() + 1),
decrement: () => set(state() - 1),
})),
),
);
// This would cause a type error (missing decrement)
const { injectBadCounterCraft } = craft(
{
name: 'badCounter',
providedIn: 'root',
implements: contract<CounterContract>(), // Error!
},
craftState('count', () =>
state(0, ({ state, set }) => ({
increment: () => set(state() + 1),
// Missing decrement!
})),
),
);Error detection for invalid composition
const { craftLogger } = craft(
{ name: 'logger', providedIn: 'root' },
craftState('logs', () =>
state([] as string[], ({ set }) => ({
clear: () => set([]),
})),
),
);
const { injectAppCraft } = craft(
{ name: 'app', providedIn: 'root' },
craftLogger(() => ({
methods: {
logsClear: signal<void>(), // OK
logsInvalidMethod: signal<void>(), // Erreur de type !
// errorMethodMsg: "Error: You are trying to add methods that are not
// defined in the connected store (logger): logsInvalidMethod"
},
inputs: {
nonExistentInput: signal(5), // Erreur de type !
// errorInputsMsg: "Error: You are trying to add inputs that are not
// defined in the connected store (logger): nonExistentInput"
},
})),
);Complex multi-store composition
// Shared auth store
const { craftAuth } = craft(
{ name: 'auth', providedIn: 'root' },
craftState('user', () =>
state({ id: null, role: 'guest' }, ({ set }) => ({
login: (user: { id: number; role: string }) => set(user),
logout: () => set({ id: null, role: 'guest' }),
})),
),
);
// Pagination feature
const { craftPagination } = craft(
{ name: 'pagination', providedIn: 'feature' },
craftInputs({ pageSize: undefined as number | undefined }),
craftState('page', ({ pageSize }) =>
state({ current: 1, size: pageSize() ?? 10 }, ({ state, set }) => ({
next: () => set({ ...state(), current: state().current + 1 }),
prev: () => set({ ...state(), current: state().current - 1 }),
})),
),
);
// Admin panel combines both
const { injectAdminPanelCraft } = craft(
{ name: 'adminPanel', providedIn: 'root' },
craftAuth(), // Shared auth
craftPagination(({ user }) => ({
// Feature pagination with dynamic pageSize based on role
inputs: {
pageSize: linkedSignal(() => (user().role === 'admin' ? 100 : 20)),
},
})),
craftQuery('adminData', ({ user, page }) =>
query({
params: linkedSignal(() => ({
userId: user().id,
page: page().current,
})),
loader: async ({ params }) => {
// Fetch data...
},
}),
),
);
const admin = injectAdminPanelCraft();
admin.userLogin({ id: 1, role: 'admin' });
admin.page().size; // 100 (admin gets larger page size)
admin.pageNext();
// -> adminData query refreshes automatically