diff --git a/Sources/Flow/Flow.docc/CoreConcepts.md b/Sources/Flow/Flow.docc/CoreConcepts.md index 9b41b80..feef3ce 100644 --- a/Sources/Flow/Flow.docc/CoreConcepts.md +++ b/Sources/Flow/Flow.docc/CoreConcepts.md @@ -54,62 +54,72 @@ struct CounterView: View { Actions can return results through `ActionTask`. The result type (`ActionResult`) can be defined for each Feature. -In this example, a child view returns a selection result to the parent, which handles navigation: +**Basic example:** ```swift import SwiftUI import Flow -struct ParentView: View { - @Environment(\.navigator) private var navigator - - var body: some View { - ChildView { selectedId in - await navigator.navigate(to: .detail(id: selectedId)) - } - } -} - -struct ChildView: View { - @State private var store = Store( - initialState: .init(), - feature: ChildFeature() - ) - let onSelect: (String) async -> Void - - var body: some View { - Button("Select") { - Task { - let result = await store.send(.select).value - if case .success(.selected(let id)) = result { - await onSelect(id) - } - } - } - } -} - -struct ChildFeature: Feature { +struct LoginFeature: Feature { @Observable final class State { - var selectedId = "" + var username = "" + var password = "" } enum Action: Sendable { - case select + case login } enum ActionResult: Sendable { - case selected(id: String) + case success + case invalidCredentials + case networkError } func handle() -> ActionHandler { ActionHandler { action, state in switch action { - case .select: - return .run { state in - let id = state.selectedId - return .selected(id: id) + case .login: + if state.username.isEmpty || state.password.isEmpty { + return .just(.invalidCredentials) // Return result immediately + } + return .run { state in // Async work with result + do { + try await api.login(state.username, state.password) + return .success + } catch { + return .networkError + } + } + } + } + } +} + +struct LoginView: View { + @State private var store = Store( + initialState: .init(), + feature: LoginFeature() + ) + + var body: some View { + VStack { + TextField("Username", text: $store.state.username) + SecureField("Password", text: $store.state.password) + Button("Login") { + Task { + let result = await store.send(.login).value + switch result { + case .success(.success): + print("Navigate to home") + case .success(.invalidCredentials): + print("Show error: Invalid credentials") + case .success(.networkError): + print("Show error: Network error") + case .failure(let error): + print("Unexpected error: \(error)") + } } } } @@ -117,13 +127,19 @@ struct ChildFeature: Feature { } ``` -This implementation provides: -- `ChildFeature` returns selection results to the parent via `ActionResult` -- `ParentView` receives results through the `onSelect` callback -- Parent controls side effects like navigation -- Everything stays within the view tree, allowing dependencies to be tracked +**Key concepts:** +- **ActionResult** - Define custom result types for your Feature +- **`.just(result)`** - Return results immediately (synchronous) +- **`.run { ... return result }`** - Return results after async work +- **`await store.send().value`** - Wait for and receive the result +- **`Result`** - Results are wrapped in Swift's Result type + +**Use cases for ActionResult:** +- Form validation with specific error types +- Navigation decisions based on action outcomes +- Showing different toasts based on success/failure patterns -See for more details. +For parent-child communication patterns and more advanced examples, see . ### @Observable Support diff --git a/Sources/Flow/Flow.docc/CoreElements.md b/Sources/Flow/Flow.docc/CoreElements.md index 3e571e2..814e6dc 100644 --- a/Sources/Flow/Flow.docc/CoreElements.md +++ b/Sources/Flow/Flow.docc/CoreElements.md @@ -14,10 +14,29 @@ Flow consists of five core elements: These elements work together to build applications. +## Quick Reference + +| Element | Purpose | Key APIs | +|---------|---------|----------| +| **Store** | Manages state and coordinates actions | `send()`, `state` property | +| **Feature** | Groups State, Actions, and Handler | `State`, `Action`, `handle()` | +| **ActionHandler** | Processes actions and returns tasks | Returns `ActionTask` | +| **ActionResult** | Defines the result type of actions | `Void` or custom type | +| **ActionTask** | Manages async execution | `.none`, `.run`, `.just`, `.cancel`, `.concatenate` | + ## Store **Store** manages state, receives actions from views, sends them to handlers, and processes results. +**Key responsibilities:** +- Holds the current state +- Receives actions from views via `send()` +- Coordinates with ActionHandler to process actions +- Returns `ActionTask` for async result handling + +**Why use `@State`?** +Store is held with `@State` to tie its lifecycle to the view. When the view appears, the store initializes; when dismissed, it cleans up automatically. + ```swift import SwiftUI import Flow @@ -37,9 +56,29 @@ var body: some View { } ``` +**Sending actions:** + +```swift +// Fire-and-forget (common for state-only updates) +store.send(.increment) + +// Handle results for navigation, validation, etc. +Task { + let result = await store.send(.save).value + if case .success = result { + // Navigate or show confirmation + } +} +``` + ## Feature -**Feature** defines State, Action, and ActionHandler in one place. +**Feature** groups State, Actions, and ActionHandler in one place, providing a complete definition of a feature's behavior. + +**Why use Feature?** +- **Cohesion** - Related logic stays together +- **Reusability** - Easy to test and reuse across views +- **Type safety** - State, Action, and ActionResult are strongly typed ```swift import Flow @@ -58,7 +97,18 @@ struct UserFeature: Feature { func handle() -> ActionHandler { ActionHandler { action, state in - // Business logic goes here + switch action { + case .load: + state.isLoading = true + return .run { state in + let user = try await api.fetchUser() + state.user = user + state.isLoading = false + } + case .logout: + state.user = nil + return .none + } } } } @@ -68,6 +118,35 @@ struct UserFeature: Feature { **ActionHandler** receives actions and current state, updates state, and returns an ActionTask. +**How it works:** +The handler is a closure that receives two parameters: +- **action** - The action to process +- **state** - The current state (mutable) + +You can update state directly and return a task describing any async work. + +**Example:** + +```swift +ActionHandler { action, state in + switch action { + case .increment: + state.count += 1 // Synchronous state update + return .none // No async work + + case .fetchData: + state.isLoading = true + return .run { state in // Async work + let data = try await api.fetch() + state.data = data + state.isLoading = false + } + } +} +``` + +**Generic type parameters:** + ```swift ActionHandler ``` @@ -85,7 +164,16 @@ Type parameters: **Return results from synchronous processing:** -Use `.just()` to return results immediately. +Use `.just()` to return results immediately without async work. + +`.just()` is similar to `.none`, but returns a custom result value: +- **`.none`** - Returns `Void` (no result) +- **`.just(result)`** - Returns a specific result value + +Common uses: +- Validation results +- Calculations based on current state +- Cache hits or default values ```swift ActionHandler { action, state in @@ -184,6 +272,16 @@ return .just(.success) **Execute async processing** +Use `.cancellable(id:cancelInFlight:)` to manage long-running tasks: + +**Parameters:** +- **`id`** - Unique identifier for the task (used for cancellation) +- **`cancelInFlight`** - Behavior when starting a new task with the same ID: + - `true` - Cancels the existing task before starting the new one + - `false` - Allows both tasks to run concurrently + +**Example:** + ```swift return .run { state in let user = try await api.fetchUser() @@ -192,6 +290,11 @@ return .run { state in .cancellable(id: "load-user", cancelInFlight: true) ``` +**Task ID naming:** +- Use descriptive names: `"search"`, `"load-user"`, `"upload-photo"` +- Keep consistent across related actions +- Task IDs are scoped to each Store instance + **Cancel running task** ```swift diff --git a/Sources/Flow/Flow.docc/Flow.md b/Sources/Flow/Flow.docc/Flow.md index 1756363..4df9998 100644 --- a/Sources/Flow/Flow.docc/Flow.md +++ b/Sources/Flow/Flow.docc/Flow.md @@ -2,6 +2,63 @@ A library for managing state in SwiftUI applications in a type-safe way. Flow provides a unidirectional data flow architecture and supports Swift 6 Approachable Concurrency. +## Overview + +Flow brings predictable state management to SwiftUI with a view-local approach. Unlike architectures with global stores (Redux, TCA), Flow keeps state scoped to views, making it easier to reason about lifecycle and dependencies. + +**Design Philosophy:** +- **View-local state** - No singleton stores to manage +- **Unidirectional flow** - Actions → Handler → State → View +- **Type-safe results** - Actions can return typed results for navigation and error handling +- **Concurrency-first** - Built for Swift 6 with MainActor isolation + +**Quick Example:** + +```swift +import SwiftUI +import Flow + +// Define your feature +struct CounterFeature: Feature { + @Observable final class State { + var count = 0 + } + + enum Action: Sendable { + case increment + } + + func handle() -> ActionHandler { + ActionHandler { action, state in + state.count += 1 + return .none + } + } +} + +// Use in your view +struct CounterView: View { + @State private var store = Store( + initialState: .init(), + feature: CounterFeature() + ) + + var body: some View { + Button("Count: \(store.state.count)") { + store.send(.increment) + } + } +} +``` + +For a complete walkthrough, see . + +**Architecture:** + +![Flow Architecture](flow-diagram.svg) + +Actions flow through the handler, update state, and SwiftUI re-renders automatically. + ## Key Features - **No global store** - Each view holds its own state with `@State` diff --git a/Sources/Flow/Flow.docc/GettingStarted.md b/Sources/Flow/Flow.docc/GettingStarted.md index e2d0384..f9448d8 100644 --- a/Sources/Flow/Flow.docc/GettingStarted.md +++ b/Sources/Flow/Flow.docc/GettingStarted.md @@ -46,9 +46,9 @@ dependencies: [ ## Build a Counter App -### Step 1 +### Step 1: Define Your Feature -Create a Feature and define State, Action, and Handler. +Create a Feature that groups **State** (data), **Actions** (events), and **ActionHandler** (logic). ```swift import Flow @@ -85,9 +85,9 @@ struct CounterFeature: Feature { } ``` -### Step 2 +### Step 2: Create Your View -Create a View and integrate the Store. +Create a View and integrate the **Store**, which manages state and coordinates actions. ```swift import SwiftUI @@ -122,6 +122,8 @@ struct CounterView: View { That's it! You've implemented the basic functionality. +> **Understanding the Code**: To learn what Feature, State, Action, ActionHandler, and Store mean and how they work together, continue to . + ## Next Steps Now that you've built a counter app, let's dive deeper into Flow's architecture: diff --git a/Sources/Flow/Flow.docc/Middleware.md b/Sources/Flow/Flow.docc/Middleware.md index 35bf97d..e60cf1e 100644 --- a/Sources/Flow/Flow.docc/Middleware.md +++ b/Sources/Flow/Flow.docc/Middleware.md @@ -52,10 +52,14 @@ struct AnalyticsMiddleware: ActionMiddleware { Use `final class` with `@unchecked Sendable` when middleware needs to track state: +> Warning: `@unchecked Sendable` bypasses Swift's thread-safety checks. You must ensure thread-safety manually. This is safe in Flow because all middleware operations run on the MainActor. + ```swift import Flow final class TimingMiddleware: ActionMiddleware, @unchecked Sendable { + // ⚠️ @unchecked Sendable: We guarantee thread-safety because Flow ensures + // all middleware methods execute on MainActor with defaultIsolation let id = "TimingMiddleware" private var startTimes: [String: Date] = [:] @@ -118,6 +122,39 @@ func handle() -> ActionHandler { [MyFeature] ✗ Error: Network error (0.145s) ``` +## Using Multiple Middleware + +Chain multiple middleware with `.use()` to compose cross-cutting concerns. + +**Example:** + +```swift +import Flow + +func handle() -> ActionHandler { + ActionHandler { action, state in + // Action processing logic + } + .use(LoggingMiddleware(category: "UserFeature")) + .use(AnalyticsMiddleware(analytics: .shared)) + .use(ErrorReportingMiddleware(reporter: .production)) +} +``` + +**Execution order:** + +Middleware executes in the order they are added: + +1. **beforeAction** - Top to bottom (Logging → Analytics → Error Reporting) +2. **Action processing** - Your handler executes +3. **afterAction** - Bottom to top (Error Reporting → Analytics → Logging) +4. **onError** - Top to bottom (Logging → Analytics → Error Reporting) + +This order ensures that: +- Logging middleware sees all actions first +- Error reporting catches all failures +- Analytics tracks after processing completes + ## Next Steps Now that you can add cross-cutting concerns with middleware, let's learn practical patterns: diff --git a/Sources/Flow/Flow.docc/PracticalGuide.md b/Sources/Flow/Flow.docc/PracticalGuide.md index 9435d56..952a264 100644 --- a/Sources/Flow/Flow.docc/PracticalGuide.md +++ b/Sources/Flow/Flow.docc/PracticalGuide.md @@ -85,6 +85,10 @@ func handle() -> ActionHandler { Cancel previous requests with `.cancellable(id:cancelInFlight:)` for user input operations like search. +**Understanding `cancelInFlight`:** +- `true` - Cancels any running task with the same ID before starting the new one +- `false` - Allows multiple tasks with the same ID to run concurrently + ```swift import Flow @@ -100,6 +104,16 @@ func handle() -> ActionHandler { state.isSearching = false } .cancellable(id: "search", cancelInFlight: true) + .catch { error, state in + state.isSearching = false + // Note: Cancellation errors are handled automatically, + // only explicit errors from api.search() reach here + if error is CancellationError { + // Task was cancelled, no action needed + } else { + state.errorMessage = error.localizedDescription + } + } case .cancelSearch: state.isSearching = false @@ -120,12 +134,18 @@ func handle() -> ActionHandler { ActionHandler { action, state in switch action { case .loadAll: + state.isLoading = true return .run { state in async let users = api.fetchUsers() async let posts = api.fetchPosts() state.users = try await users state.posts = try await posts + state.isLoading = false + } + .catch { error, state in + state.isLoading = false + state.errorMessage = "Failed to load data: \(error.localizedDescription)" } } } @@ -134,7 +154,18 @@ func handle() -> ActionHandler { ## Task Priority -Set task priority to execute important operations first. +Set task priority to control how the system schedules async operations. + +**Priority levels:** + +| Priority | When to Use | Example Use Cases | +|----------|-------------|-------------------| +| `.high` | User is actively waiting for results | Critical data loading, search results | +| `.userInitiated` | User-triggered operations | Button actions, form submissions | +| `.utility` | Improve UX but not urgent | Prefetching, caching next page | +| `.background` | Can run anytime | Analytics uploads, logs, cleanup | + +**Example:** ```swift import Flow @@ -143,7 +174,7 @@ func handle() -> ActionHandler { ActionHandler { action, state in switch action { case .loadCriticalData: - // Critical data loading that users are waiting for + // User is waiting for this data return .run { state in let data = try await api.fetchCriticalData() state.data = data @@ -151,33 +182,19 @@ func handle() -> ActionHandler { .priority(.high) case .uploadAnalytics: - // Analytics data upload in background + // Background task, can run anytime return .run { state in try await analytics.upload(state.events) state.events.removeAll() } .priority(.background) - - case .loadUserProfile: - // User-initiated operation - return .run { state in - let profile = try await api.fetchProfile() - state.profile = profile - } - .priority(.userInitiated) - - case .prefetchNextPage: - // Utility operation (prefetch cache, etc.) - return .run { state in - let nextPage = try await api.fetchNextPage() - state.cachedPages.append(nextPage) - } - .priority(.utility) } } } ``` +> Note: Priority affects scheduling but doesn't guarantee execution order. Use `.concatenate` when strict ordering is required. + ## Method Chaining Combine multiple methods to set priority, cancellation, error handling, and more on tasks. @@ -263,8 +280,12 @@ struct ChildView: View { Button("Validate") { Task { let result = await store.send(.validate).value - if case .success(let validationResult) = result { + switch result { + case .success(let validationResult): onValidate(validationResult) + case .failure(let error): + print("Validation error: \(error)") + // Handle unexpected errors (network issues, etc.) } } }