Article
Lifting Features
Write each feature against its own small types, then lift it up to the app’s global types before composing.
Overview
The point of SwiftRex’s composition is that a feature never needs to know about the whole app. You write a Reducer, Middleware, or Behavior against a local (Action, State, Environment) — types that describe only that feature — and lift it to the app’s global types right before you combine it with everything else. Lifting:
narrows the action — the lifted unit runs only when the global action maps to the local one (otherwise it’s skipped);
focuses the state — it reads the relevant slice, runs, and writes the slice back;
narrows the environment (Middleware/Behavior only) — it projects the app’s dependencies down to what the feature needs.
The lift API is the same shape on Reducer, Behavior, and Middleware — learn it once. After lifting, every feature is at the global types, so they compose with combine / the @ReducerBuilder DSL. See The Algebra for why that composition is lawful.
The three axes
// counterReducer: Reducer<CounterAction, Int>
// lifted: Reducer<AppAction, AppState>
let lifted = counterReducer.lift(
action: \AppAction.counter, // narrow the action
state: \AppState.counter // focus the state
)
Behavior and Middleware add a third axis, the environment:
let lifted = searchBehavior.lift(
action: \AppAction.search,
state: \AppState.search,
environment: \AppEnvironment.searchAPI
)
Lift one axis at a time with the single-axis primitives — liftAction, liftState, and liftEnvironment on Behavior and Middleware — or all three together through a Relay.Scope, the one carrier every host’s lift/projection accepts. Build a scope with the fluent builder (.action(…).state(…).environment(…)); each host constrains on only the capabilities it needs.
Choosing the right optic per axis
Which kind of key path or optic you pass depends on the shape of your global type. SwiftRex accepts plain KeyPaths and the FP optics interchangeably.
Action — narrow
\.case key path — PrismKeyPath<GlobalAction, LocalAction>, generated by @Prisms (the enum becomes Prismatic): \AppAction.counter. The unit is skipped when the case doesn’t match. This is the recommended spelling.
Prism<GlobalAction, LocalAction> — AppAction.prism.counter, the same optic in value form (composes with >>>); Prism(path) recovers it from a \.case path.
State — focus
WritableKeyPath<GlobalState, LocalState> — \AppState.counter, when the slice is a var stored property.
Lens<GlobalState, LocalState> — AppState.lens.counter (@Lenses), when the slice is a let / immutable property; lenses compose through nested immutables with >>>.
Prism<GlobalState, CaseState> — when the state itself is an enum and the feature applies to one case (SessionState.prism.loggedIn); the unit runs only in that case.
AffineTraversal<GlobalState, LocalState> — when the focus may be absent (preview returns nil); the unit is skipped when there’s nothing to focus.
Environment — narrow
A projection (\AppEnvironment.searchAPI or a closure) maps the app’s dependencies to the feature’s. Library modules that don’t know the app’s environment commonly export a (Dependencies) -> Behavior<…> factory and let the app inject.
Direction of the arrows
Lifting is type-directed; the arrows explain why each axis wants the optic it does:
Incoming actions are optional — GlobalAction → LocalAction?. A unit may ignore actions that aren’t for it; nil means skipped. That’s a Prism’s preview.
Outgoing actions are total — LocalAction → GlobalAction. An action a feature dispatches must embed into the app’s action — you can’t ignore what a feature has to say. That’s the Prism’s review (the case constructor). One Prism (or \.case path) covers both directions of the action axis at once, which is why action lifting is always a prism, never a plain key path.
Reducer state is read–write — GlobalState ←→ LocalState: read the slice, run, write it back. That’s WritableKeyPath/Lens.
Middleware/Behavior effect state is read-only, but every lift also threads the supervise axis through the same focus (channels re-embed, sub-state gone ⇒ its channels cancel), so a Behavior scope uses a writable state lane (Relay.StateAxis.ReadsWrites / Relay.StateAxis.Writes) while a Middleware scope needs only a readable one (Relay.StateAxis.Reads).
Closures — the escape hatch
Every key path and optic parameter is a compact spelling of a function. When a transformation doesn’t fit an optic — computed slices, legacy types, cross-cutting projections — pass the function directly:
// state focus, as closures
{ (global: AppState) -> Piece in global.something.piece } // get
{ (global: inout AppState, new: Piece) -> Void in global.something.piece = new } // set
// output-action embed is a case constructor — a function reference, not a KeyPath
AppAction.counter
Identity, ignore and absurd
Three degenerate lifts cover the axes a unit doesn’t use:
identity ({ $0 }) — the axis is already at the global type; pass it through unchanged.
ignore ({ _ in }) — the unit needs no state, so its State is Void; any global state “narrows” to Void by discarding it.
absurd ((Never) -> Anything) — the unit never dispatches, so its output action is Never; absurd satisfies the embed requirement with a function that can never be called.
Void and Never are dual: everything maps into Void (the terminal object — exactly one value), and Never maps into everything (the initial object — no values). absurd can promise any return type because nobody can ever hand it a Never to make good on the promise.
Optionals and collections — same builder, dedicated hosts
Beyond the total re-index, three hosts run a unit across variable state. They take the same leading-dot Relay.Scope — the element/optional addressing rides in the lanes, so the spelling stays naked. Each host is constrained on its own decorator capabilities, so the compiler only offers a host the lanes it can honour, and the unit always sees the unwrapped focus (never Element?).
liftOptional (0-or-1) — an Absent action + Absent environment + affine state scope. Runs only while the focus is .some; a complete no-op (no mutation, no effect, no supervise) while nil:
dayBehavior.liftOptional(.state(\AppState.currentDay)) // currentDay: DayDetail.State?
liftCollection (0-or-n, route one) — the action lane carries the element id (an ElementAction prism), the state lane is a keyed container. Locate by Identifiable id, a custom key path, position, or a dictionary key:
todoBehavior.liftCollection(.action(AppAction.prism.todo).state(\AppState.todos).environment(\.todoEnv))
todoBehavior.liftCollection(.action(AppAction.prism.todo).state(\AppState.todos, id: \.slug)…) // custom id
todoBehavior.liftCollection(.action(AppAction.prism.todo).state(indexed: \AppState.todos)…) // by position
todoBehavior.liftCollection(.action(AppAction.prism.cfg).state(dictionary: \AppState.configs)…) // dictionary
// dispatch: store.dispatch(.todo(ElementAction(todo.id, action: .toggleDone)))
liftEach (0-or-n, broadcast) — one action drives every element. Its action lane bridges a plain inbound case and the id-addressed outbound case:
todoBehavior.liftEach(.action(broadcast: AppAction.prism.tickAll, into: AppAction.prism.todo).state(\AppState.todos)…)
The same lanes drive Reducer and Middleware lifts, and a per-element projection(_:element:) (whose projected state is Element? — the view unwraps). Per-element effect scheduling is tagged per element, so one row’s .debounce(id:) never collides with another’s.
Note
For the full picture — the input zoo (prism / key path / macro-free closures), all four locators, the view-side projections, transpose(), Presentation, and two-way bindings — see Optionals and Collections.
Putting it together
Lifting is what lets independently-built feature modules meet at the Store:
let app = Behavior.combine(
authBehavior.lift(.action(AppAction.prism.auth).state(\.auth).environment(\.auth)),
searchBehavior.lift(.action(AppAction.prism.search).state(\.search).environment(\.searchAPI)),
todoReducer.liftCollection(.action(AppAction.prism.todo).state(\.todos)).asBehavior()
)
let store = Store(initial: .init(), behavior: app, environment: appEnv)
Note
A Relay.Scope builds up axis by axis (.action(…).state(…).environment(…)), each step returning a scope with the other axes Absent. The leading-dot form works wherever the host’s global types are inferable — a bare \.case action key path infers its root fine in store init, projection, a function return, or a typed Behavior<Global,…> local. The only spot that can’t infer is inlining directly into Behavior.combine(...) (its bidirectional inference doesn’t pin the global): there, either lift into a typed local first and combine the locals, or pass an action prism (AppAction.prism.auth). For a declared scope with no surrounding host, start from Relay.Empty.
See Also
Related Documentation