Effects
Target Audience: Advanced Goal: Orchestrate reactive side effects from store actions without polluting reducers.
Reducers should stay deterministic and side-effect free. Effects are the reactive boundary where you listen to actions, join state, call external services, and optionally dispatch follow-up actions.
In HexaJS, effects are built on RxJS and tagged through createEffect(...). The runtime then discovers and subscribes them during bootstrap.
Why effects exist
Use effects when reducer logic is no longer just state + action -> nextState.
Typical cases:
- derive a follow-up action from multiple store slices,
- react to one action by calling a service,
- coordinate async flows without putting RxJS subscriptions inside reducers,
- run logging, analytics, or persistence work with
dispatch: false.
Runtime model
HexaJS effect support is centered on two functions:
createEffect(...): tags an Observable so the framework knows it is an effect.subscribeEffects(...): scans an injectable instance, subscribes every tagged effect, and routes emitted actions back intostore.dispatch(...).
At runtime the flow is:
- An
@Injectable()service exposes effect properties. - Each effect property calls
createEffect(() => observable$, config?). - The generated bootstrap resolves that service.
subscribeEffects(...)discovers the tagged properties.- Emitted actions are dispatched back into the store unless
dispatch: falseis set.
Example: filter content clips after sync
The clip-volt example shows the core pattern clearly.
import { Injectable, inject } from '@hexajs-dev/common';
import { Actions, HexaContentStore, createEffect, ofType, select } from '@hexajs-dev/core';
import { map, withLatestFrom } from 'rxjs/operators';
import * as ContentActions from './content.actions';
import { ContentState } from './content.reducer';
import { selectClips, selectConfig } from './content.selectors';
@Injectable()
export class ContentEffects {
private actions$ = inject(Actions);
private store = inject<HexaContentStore<ContentState>>(HexaContentStore);
filterClips$ = createEffect(() =>
this.actions$.pipe(
ofType(ContentActions.CLIPS_SYNCED, ContentActions.CONFIG_SYNCED),
withLatestFrom(
this.store.pipe(select(selectClips)),
this.store.pipe(select(selectConfig))
),
map(([, clips, config]) => {
const filteredClips = this.applyFilters(clips, config);
return ContentActions.clipsFiltered({ filteredClips });
})
)
);
}
This effect does three things:
- listens for two action types,
- joins the latest store state needed for the decision,
- emits a new action with the derived payload.
That keeps reducers simple while still giving you a typed, reactive pipeline.
Core building blocks
Actions
Inject Actions when the effect should react to the action stream.
private actions$ = inject(Actions);
This is the primary source for event-driven store orchestration.
ofType(...)
Use ofType(...) to narrow the stream to the actions that should trigger the effect.
this.actions$.pipe(ofType(loadItems, refreshItems));
This keeps effect pipelines targeted and avoids repeated condition checks in map or tap stages.
select(...) and state joins
Use store selectors when the effect depends on current state in addition to the incoming action.
withLatestFrom(
this.store.pipe(select(selectClips)),
this.store.pipe(select(selectConfig))
)
This pattern is especially useful when actions announce that something changed, but the final derived output depends on multiple slices.
Dispatching effects
By default, effects are dispatching effects. If an effect emits values that look like actions, HexaJS sends them back through dispatch(...).
loadSucceeded$ = createEffect(() =>
this.actions$.pipe(
ofType(loadRequested),
map(() => loadCompleted())
)
);
The runtime only dispatches emissions shaped like actions with a string type field. This keeps accidental emissions from being routed into the store.
Non-dispatching effects
For telemetry, logging, imperative integration, or other side-effect-only flows, set dispatch: false.
audit$ = createEffect(() =>
this.actions$.pipe(
ofType(saveCompleted),
tap(action => this.logger.log('Saved item', action.payload.id))
),
{ dispatch: false }
);
Use this sparingly. If the pipeline is producing new state transitions, prefer returning a real action instead.
State-subscription effects
Not every effect needs to react to an action. When the goal is to respond to any state change — regardless of which action caused it — subscribe to the store directly instead of the Actions stream.
This is the correct pattern for persistence, logging, and any side effect that should fire whenever a slice changes value.
import { HexaContext, Injectable, inject } from '@hexajs-dev/common';
import { HexaBackgroundStore, createEffect, select } from '@hexajs-dev/core';
import { skip, switchMap } from 'rxjs/operators';
import { from } from 'rxjs';
import { BackgroundState } from './background.reducer';
import { StorageService } from '../services/storage.service';
@Injectable({ context: HexaContext.Background })
export class BackgroundEffects {
private store = inject(HexaBackgroundStore<BackgroundState>);
private storage = inject(StorageService);
persistClips$ = createEffect(() =>
this.store.pipe(
select(state => state.clips),
skip(1),
switchMap(clips => from(this.storage.persistClips(clips))),
),
{ dispatch: false },
);
}
Key points:
- No
ofType— the source is the store state observable, not theActionsstream. The effect fires whenever theclipsslice reference changes, regardless of which action triggered it. skip(1)— the store emits its initial state synchronously on subscription. Skip that first value to avoid writing data that was just loaded from storage byinitState.switchMap— cancels any in-flight async work if a newer state arrives before the previous call resolves. Correct for full-replacement writes; useconcatMapif write order must be preserved.dispatch: false— this effect produces no actions.
The store only emits when the selected slice reference actually changes (select applies distinctUntilChanged internally), so this pattern does not fire on unrelated dispatches.
Resilience and dead-stream recovery
HexaJS protects effects from permanently dying after an unhandled error.
The runtime wraps each effect with:
catchError(...)to log the failure,EMPTYto complete the broken inner stream,retry({ delay: () => timer(0) })to re-subscribe immediately.
That means a transient failure does not permanently disable the effect pipeline for the lifetime of the context.
This is useful for extension runtimes where content and background contexts may live for a long time and must survive intermittent failures.
Design guidance
- Keep reducers pure and move orchestration into effects.
- Keep effect classes injectable so they can resolve services and store instances cleanly.
- Keep effects coarse-grained enough to express a workflow, not one trivial operator per class.
- Keep state joins explicit with selectors instead of reaching into raw state objects ad hoc.
- Keep
dispatch: falsefor true side-effect-only work. - Keep external IO isolated behind services instead of embedding it directly in large operator chains.
When not to use effects
Do not use effects when:
- the operation is a synchronous state transition that belongs entirely in a reducer,
- the logic is view-local and does not belong to the store lifecycle,
- a handler or controller can perform the work directly before dispatching a final action.
Effects are for reactive orchestration, not as a replacement for every service method.