Redux-style state management for SwiftUI. State lives in one observable store, mutations go through reducers, and side effects run as async closures. Macros generate the observability boilerplate. Built-in plugins handle persistence and undo/redo. Optional plugins ship ready-made paywalls, version killswitches, parental gates, analytics, feature flags, and SwiftData/iCloud persistence.
- Predictable mutations. Reducers are pure and synchronous. Async work is explicit, off the MainActor, and dispatches results back through actions.
- No observer-class boilerplate.
@Swiduxwrites the@Observablecompanion class for you. SwiftUI gets per-property observation without hand-maintained class trees. - Persistence is invisible.
EntityStoretracks every change;PersistencePlugindebounces and batches them. You never writesave()in a feature. Scalar preferences (theme, sort order) get a separate testable abstraction —KeyValueStore— instead of reaching forUserDefaults.standard. - Batteries included. Undo/redo, version killswitches, parental gates, RevenueCat-shaped paywalls, analytics, and remote feature flags are one
plugins.register(...)away. - Strict-concurrency-native. Built for Swift 6 from the ground up.
Sendable,@MainActor, and@concurrentare wired into the dispatch cycle so your app composes safely with async/await and SwiftData.
Xcode. File > Add Package Dependencies, paste https://github.com/heirloomlogic/Swidux, set Up to Next Major from 1.0.0. Add the products you need:
Swidux— coreSwiduxKillswitch— version-blocking plugin (optional)SwiduxParentalGate— math-challenge gate plugin (optional)SwiduxPaywall— paywall + entitlement plugin (optional)SwiduxDevPaywallUI— SDK-free debug paywall sheet forSwiduxPaywall(optional)SwiduxAnalytics— event-tracking plugin (optional)SwiduxFeatureFlags— typed feature flags + A/B variants plugin (optional)SwiduxPersistence— SwiftData persistence (optional)SwiduxCloudKitSync— opt-in iCloud/CloudKit sync, layered onSwiduxPersistence(optional)
Package.swift.
.package(url: "https://github.com/heirloomlogic/Swidux", from: "1.0.0"),.product(name: "Swidux", package: "Swidux"),
.product(name: "SwiduxPaywall", package: "Swidux"), // optional
.product(name: "SwiduxDevPaywallUI", package: "Swidux"), // optional
.product(name: "SwiduxKillswitch", package: "Swidux"), // optional
.product(name: "SwiduxParentalGate", package: "Swidux"), // optional
.product(name: "SwiduxAnalytics", package: "Swidux"), // optional
.product(name: "SwiduxFeatureFlags", package: "Swidux"), // optional
.product(name: "SwiduxPersistence", package: "Swidux"), // optional
.product(name: "SwiduxCloudKitSync", package: "Swidux"), // optionalThe shape of a Swidux app, condensed:
import SwiftUI
import Swidux
@Swidux
nonisolated struct AppState: Equatable, Sendable {
var counters: EntityStore<Counter> = .init()
}
enum AppAction: Sendable {
case increment(UUID)
}
typealias AppStore = Store<AppState, AppAction>
extension Store where State == AppState, Action == AppAction {
static func configured() -> AppStore {
Store(initialState: AppState(), reducer: { state, action in
if case .increment(let id) = action {
state.counters.modify(id) { $0.count += 1 }
}
return nil
})
}
}Walk through a complete counter app — actions, reducer, plugins, views, undo, async effects — in Build Your First Swidux App. The runnable version of that tutorial lives at Examples/Counter/.
Optional plugins ship as separate library targets. Wire any of the cross-cutting ones with three keypath/closure pieces and a plugins.register(...) call; the persistence pair is registered through a coordinator.
Block users on outdated app versions by checking remote config (minimumSupportedVersion, blockedVersions, blockedRanges) against the current CFBundleShortVersionString. Cache-aware (.fetch uses the local cache when fresh; .forceFetch always hits the network), with cached fallback on network failure so a flaky launch still yields a usable verdict. Drop in the killswitchBlocker(verdict:onUpdate:) view modifier and the blocker overlays automatically when blocked. See Add a Version Killswitch.
plugins.register(KillswitchPlugin(state: \.killswitch, action: AppAction.killswitch, extractAction: { if case .killswitch(let a) = $0 { return a }; return nil }, service: .live(endpoint: configURL), appVersion: { Bundle.main.shortVersion }))Guard sensitive actions (purchases, settings changes, leaving kids mode) behind a math challenge. Passed reasons are remembered for the session — once "purchase" clears, subsequent .request(reason: "purchase") auto-pass. Plug in custom challenge generators via ParentalChallengeSource. See Add a Parental Gate.
plugins.register(ParentalGatePlugin(state: \.parentalGate, action: AppAction.parentalGate, extractAction: { if case .parentalGate(let a) = $0 { return a }; return nil }))Manage paywall presentation, entitlement observation, and purchase restoration. The plugin is purchase-agnostic — it doesn't know about products or prices. You implement a PaywallService against StoreKit, RevenueCat, or a custom backend; the plugin handles state. For RevenueCat, drop in the ready-made adapter from SwiduxRevenueCatPaywall — it ships RevenueCatPaywallService and a SwiftUI sheet built on RevenueCatUI. Check store.paywall.isGateSatisfied before running a pro feature. See Add a Paywall.
plugins.register(PaywallPlugin(state: \.paywall, action: AppAction.paywall, extractAction: { if case .paywall(let a) = $0 { return a }; return nil }, service: RevenueCatPaywallService()))SwiduxDevPaywallUI is an opt-in, SDK-free debug paywall sheet for driving SwiduxPaywall while the purchase vendor decision is still open. Paired with the built-in SimulatedPaywallService, it lets a developer or QA tester grant Pro/Trial/Permanent, run real Restore/Refresh flows, and simulate failure or latency — all flowing through the real plugin pipeline, no SDK required. The .devPaywall(state:service:onAction:) modifier mirrors the shape of the vendor paywall sheet, so the call site is unchanged when you adopt a real provider. See Add a Paywall and the Paywall Plugin Reference.
someView.devPaywall(state: store.paywall, service: simulatedPaywallService, onAction: { store.send(.paywall($0)) })Centralize event tracking behind a provider-agnostic AnalyticsService. A declarative AnalyticsMapper turns domain actions into tracked events in afterReduce, auto-identify keeps people-properties in sync with state, and screen views, opt-out, and shutdown flushing are first-class. The plugin doesn't know about your backend — implement AnalyticsService against Amplitude, PostHog, Segment, or a custom server, or drop in the ready-made SwiduxMixpanelAnalytics adapter for Mixpanel. See Add Analytics.
plugins.register(AnalyticsPlugin(state: \.analytics, action: AppAction.analytics, extractAction: { if case .analytics(let a) = $0 { return a }; return nil }, service: MixpanelAnalyticsService(token: "..."), mapper: mapper, identity: identity))Typed feature flags, A/B variants, and remote-tunable scalar values from a single JSON config fetched through a provider-agnostic FeatureFlagsService. Bucketing is pure FNV-1a — the same input always lands in the same bucket, with no network round-trip per read — keyed on a Keychain-backed deviceID so it stays stable across reinstall and is shared with analytics. Local overrides give QA a one-action toggle that wins over remote evaluation, and a FlagDescriptor manifest plus one FlagGovernance test keeps stale "forever flags" out of the codebase. See Add Feature Flags.
store.register(plugin: FeatureFlagsPlugin(state: \.featureFlags, action: AppAction.featureFlags, extractAction: { if case .featureFlags(let a) = $0 { return a }; return nil }, service: HTTPFeatureFlagsService(url: configURL), deviceIDKeyPath: \.deviceID, keyValueStore: kv))SwiduxPersistence makes SwiftData persistence a declare-and-register concern. Annotate a domain entity with @Persisted and the macro generates its @Model shadow class, the init(from:)/toDomain()/update(from:) converters, and the PersistableEntity conformance — no hand-written model, DB actor, or StateWriter. A generic EntityDB actor and a PersistenceCoordinator (which reuses the core PersistencePlugin) handle the container, writers, and a merge-only re-hydration path.
@Persisted struct Card: Identifiable, Equatable, Sendable { var id: UUID; var quote: String }
let container = try ContainerFactory.makeLocalContainer(models: [CardModel.self])
let persistence = PersistenceCoordinator<AppState, AppAction>(entities: [.entity(\.cards)], container: container)
plugins.register(persistence.corePlugin)
await persistence.hydrate(into: &initialState)SwiduxCloudKitSync adds opt-in iCloud sync with a runtime opt-out toggle (SyncCoordinator), launch-time entitlement/account detection (SyncPreflightService → SyncStatus), and a merge-based remote-change observer. Linking this product is the single signal that an app needs the iCloud/CloudKit/Push entitlement family; a local-only app links only SwiduxPersistence.
Vendor-specific adapters live in their own repositories so the SDK dependency stays out of the core graph. Each ships a drop-in service plus a preview mock, and publishes its own DocC reference.
SwiduxRevenueCatPaywall— RevenueCat adapter forSwiduxPaywall. ShipsRevenueCatPaywallService(drop-inPaywallService),MockRevenueCatPaywallServicefor previews, andSwiduxRevenueCatPaywallUI, a SwiftUI sheet built on RevenueCatUI. DocC reference.SwiduxMixpanelAnalytics— Mixpanel adapter forSwiduxAnalytics. ShipsMixpanelAnalyticsService(drop-inAnalyticsServicethat forwards to the Mixpanel SDK and mapsAnalyticsValueto native Mixpanel types) andMockMixpanelAnalyticsServicefor previews. DocC reference.
@Swidux and @Slice eliminate the observer-class boilerplate that would otherwise sit between your value-type state and SwiftUI's @Observable requirement.
// Before — you'd hand-write an @Observable class mirroring AppState plus
// a SwiduxObservable conformance with pack/unpack/restore methods.
// After:
@Swidux
nonisolated struct AppState: Equatable, Sendable {
var items: EntityStore<Item> = .init()
@Slice var ui: UIState = .init()
}The macros emit an AppStateObserver class and a SwiduxObservable extension. @Slice ensures composed state slices keep per-property observation. See Macros Reference.
SwiduxPersistence adds @Persisted (with the property markers @Relation, @ForeignKey, @Inline, @Ignored), which generates a domain entity's SwiftData @Model shadow and converters. It operates on entities stored in an EntityStore — a different layer than @Swidux, which annotates state containers — so the two never apply to the same type.
Note for Redux users:
@Sliceis not Redux Toolkit'screateSlice. Swidux slices don't own reducers —@Sliceis a structural marker for nested@Swiduxproperties so SwiftUI gets per-property observation. Apologies for the term overload; we picked it for memorability over literal equivalence.
Full DocC reference at https://heirloomlogic.github.io/Swidux/documentation/swidux/. Starting points by intent:
- I want to learn — Build Your First Swidux App
- I want to add a paywall / killswitch / parental gate / analytics / feature flags / persistence — the per-plugin how-tos linked above
- I want the API — Macros Reference, EntityStore Guide, Persistence Middleware Guide, KeyValueStore Guide, Undo / Redo
- I want to understand the design — Architecture Guide, Plugin Architecture, Design Principles
- I want to write my own plugin — Building a Domain Plugin
- Something isn't working — Troubleshooting
Swidux ships a companion agent skill, swidux-ref, with the architecture rules and copy-pasteable code templates your AI coding assistant needs. It lives in the public heirloomlogic/skills repo.
Install (GitHub CLI, recommended):
gh skill install heirloomlogic/skills swidux-ref --agent claude-code --scope user--scope user installs to ~/.claude/skills/. Pass --scope project to commit it to the current project's .claude/skills/ so the whole team gets it. Pin a version with [email protected]. Requires gh ≥ v2.90.0.
Install (skills.sh):
npx skillsadd heirloomlogic/skillsFor Codex, Cursor, Gemini CLI, and other agents — plus a curl-only fallback — see the Agent Skill article.
Swift 6.2+ / Xcode 26+, macOS 15+ / iOS 18+. Strict concurrency (.swiftLanguageMode(.v6)).
Release history lives in CHANGELOG.md. Development setup — including the .dev-tooling sentinel that gates the lint and DocC tooling — is covered in CONTRIBUTING.md.
See LICENSE for details.
