Decorators
Stack decorators on action class methods to add cross-cutting behavior.
import {
OnError,
OnSuccess,
Debounce,
Throttle,
Authorized,
Retry,
Queue,
Log,
Validate,
} from 'comwit'
Built-in decorators
| Decorator | Purpose |
|---|---|
@OnError(fn) | Error handler. Receives the error. Re-throw to propagate. |
@OnSuccess(fn) | Runs after successful completion. |
@Debounce(ms) | Debounces the method call. Pair with flushDebounce / cancelDebounce. |
@Throttle(ms) | Throttles the method call. Pair with flushThrottle / cancelThrottle. |
@Authorized({ when, onDeny }) | Auth guard. when: () => boolean | Promise<boolean> |
@Retry(count, options?) | Retries on error up to count times. options: ms delay, or { delay?, backoff? } ('fixed' | 'exponential'). |
@Queue(strategy?) | Concurrency control. strategy: 'drop' (default) | 'queue' | 'replace'. |
@Log(level?) | Logs method execution with timing. Levels: 'info' | 'debug' | 'warn'. |
@Validate(validators) | Validates arguments before execution. Throws ValidationError on failure. |
Example
import { action, OnError, OnSuccess, Debounce } from 'comwit'
export const postActions = action<Pick<PostActions, 'create' | 'search'>, AppContext>(
({ state, context }) => {
class Actions {
private model = state(post)
@OnSuccess(() => context.router.push('/posts'))
@OnError((e) => toast.error(e instanceof Error ? e.message : 'Failed'))
async create(title: string) {
const created = await api.post.create({ title })
this.model.posts.data.push(created)
}
@Debounce(300)
async search(keyword: string) {
await this.model.posts.query(keyword)
}
}
return new Actions()
}
)
Flushing pending @Debounce / @Throttle calls
@Debounce and @Throttle schedule trailing calls. There are situations where
you need to fire that pending call immediately instead of waiting for the
window to close — typically when the surrounding state is about to be torn
down and the trailing call would be silently dropped.
A canonical example is autosave on a card detail sheet:
User types in a draft → keystrokes call
@Debounce(700) persistSoon()→ user closes the sheet within the debounce window → the surrounding state (e.g. the active draft id) resets → the timer fires after the close and the method exits early because its preconditions no longer hold → the user's last input is silently lost.
Helpers
import { flushDebounce, cancelDebounce, flushThrottle, cancelThrottle } from 'comwit'
// Fire the pending trailing call now. Resolves with the method's return
// value, or `undefined` if there was no pending call. Rejects if the
// flushed method throws — surrounding decorators (e.g. @OnError) see the
// error exactly as if it had fired on its own.
flushDebounce<T>(target: T, methodName: keyof T & string): Promise<unknown>
// Drop the pending trailing call. No-op when nothing is pending.
cancelDebounce<T>(target: T, methodName: keyof T & string): void
// Same shape, for @Throttle.
flushThrottle<T>(target: T, methodName: keyof T & string): Promise<unknown>
cancelThrottle<T>(target: T, methodName: keyof T & string): void
target accepts either the action class instance or the merged actions
object returned by useAction(...). The latter is the common case when the
method to flush lives in a different action slice from the caller.
class DraftActions {
@LoginRequired
@Debounce(700)
@OnError(toastErr)
async persistSoon() {
await this.saveDraft()
}
}
// In another action slice — flush before navigating away.
await flushDebounce(this.draftActions, 'persistSoon')
The full decorator stack composes: a flushed call propagates through
@OnError, @LoginRequired, @Retry, etc. exactly as a naturally-fired
trailing call would, because the helpers route the same code path that the
internal timer would.
Why helpers and not instance.method.flush()
A more ergonomic API would be instance.persistSoon.flush() — close to
es-toolkit's own shape. We chose helpers for v1.x because comwit's
interceptor pipeline (composeInterceptors) only forwards call signatures —
properties attached to the function (.flush, .cancel) get silently
dropped whenever any other decorator sits outside @Debounce in the stack.
A method-shape change requires every interceptor to forward attached props
as a contract, plus typing changes flowing through StageMethodDecorator.
That's a coordinated cross-cutting commitment we'll roll out as an opt-in
later (issue
#71). The helpers here
remain the canonical path for cross-slice cases — they will keep working
unchanged.
Custom Decorators with intercept()
Build reusable decorators with full lifecycle control.
import { intercept } from 'comwit'
intercept() supports two modes:
- Immediate mode — hooks applied at decoration time. Use when you don't need runtime context.
- Lazy mode — factory receives
ActionContextwithstateandcontext, resolved when the action binds to a provider.
Immediate mode
const MyLog = intercept({
onBefore: (...args) => console.log('called with', args),
onSuccess: (result) => console.log('returned', result),
onError: (err) => console.error('failed', err),
})
Available hooks: onBefore, onSuccess, onError, onSettled, intercept.
Lazy mode
Use when the decorator needs access to model state or provider context.
const LoginRequired = intercept<AppContext>(({ state, context }) => {
const u = state(user)
return {
intercept: (execute, args) => {
if (!u.me) {
context.router.push('/login')
return
}
return execute(...args)
},
}
})
Use it like any other decorator:
@LoginRequired
@OnSuccess(() => context.router.push('/posts'))
async create(title: string) {
const created = await api.post.create({ title })
this.model.posts.data.push(created)
}
Decorators with arguments
Create parameterized decorators by wrapping intercept() in a function:
function MinLength(field: string, min: number) {
return intercept({
intercept: (execute, args) => {
if (args[0]?.length < min) throw new Error(`${field} must be at least ${min} chars`)
return execute(...args)
},
})
}
@Validate
Validates arguments before execution. Validators map keys to argument positions.
import { Validate, ValidationError } from 'comwit'
class TodoActions {
@Validate({
title: (v) => (typeof v === 'string' && v.length > 0) || 'Title required',
priority: (v) => [1, 2, 3].includes(v) || 'Priority must be 1-3',
})
async createTodo(title: string, priority: number) {
// ...
}
}
Each validator returns true for pass, or a string error message. On failure, throws ValidationError with an .errors record containing all failed validations.