FlickDocumentation
Swift SDK

Expose actions

Pass values to Swift, return typed results, and stream inputs and outputs.

Pass an input and return a result

One call sends { values: [2, 3, 5] } to Swift and receives 10. These examples use the SDK setup and Flick control from Quick start.

MyApp/MyApp+Flick.swift — actions excerpt
import FlickSDK

@FlickScope(url: "https://acme-corp.com")
enum MyApp {
    enum Action {
        @FlickAction(failure: Never.self, output: .effect(Int.self))
        case total(values: [Int]) // React input: { values: readonly number[] }
    }
}
MyApp/ContentView.swift
import FlickSDK
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Ready to total values")
            .taskForFlick(for: \.total) { input in
                input.values.reduce(0, +)
            }
    }
}
Flick.tsx
import { Button, Flex, Text, useContextAction } from '@flickfyi/core'
import { Effect } from 'effect'
import { useState } from 'react'
import { MyApp } from 'com.acme.myapp'

export default function Total() {
  // (input: Effect.Effect<{ readonly values: readonly number[] }>)
  //   => Effect.Effect<number>
  const total = useContextAction(MyApp.actions.total)
  const [result, setResult] = useState(0)

  return (
    <Flex>
      <Text>Total: {result}</Text>
      <Button onClick={async () => {
        // The array is one input value, supplied by an Effect
        const input = Effect.succeed({ values: [2, 3, 5] })
        setResult(await Effect.runPromise(total(input))) // 10
      }}>
        Total 2, 3, and 5
      </Button>
    </Flex>
  )
}

The case argument [Int] becomes readonly number[]; the declared output Int becomes number. total(input) describes the call, and Effect.runPromise executes it.

Return a typed failure

Suppose your app requires at least one value. Swift can reject an empty array with an error whose fields are available to the Flick.

MyApp/TotalError.swift
import FlickSDK

// FlickObjectValue exposes message as a typed field in React
struct TotalError: Error, FlickObjectValue {
    let message: String
}
MyApp/MyApp+Flick.swift — replace MyApp.Action.total
@FlickAction(failure: TotalError.self, output: .effect(Int.self))
case total(values: [Int])
MyApp/ContentView.swift — replace the total handler
.taskForFlick(for: \.total) { input async throws(TotalError) in
    guard !input.values.isEmpty else {
        throw TotalError(message: "Send at least one value")
    }
    return input.values.reduce(0, +)
}
Flick.tsx
import { Button, Flex, Text, useContextAction } from '@flickfyi/core'
import { Effect } from 'effect'
import { useState } from 'react'
import { MyApp } from 'com.acme.myapp'

export default function Total() {
  // (input: Effect.Effect<{ readonly values: readonly number[] }>)
  //   => Effect.Effect<number, TotalError>
  const total = useContextAction(MyApp.actions.total)
  const [message, setMessage] = useState('Choose some values')

  const calculate = async (values: number[]) => {
    const result = await Effect.runPromise(
      total(Effect.succeed({ values })).pipe(
        // Turn either a failure or a successful result into display text
        Effect.match({
          onFailure: error => error.message, // TotalError.message from Swift
          onSuccess: value => `Total: ${value}`,
        }),
      ),
    )
    setMessage(result)
  }

  return (
    <Flex>
      <Text>{message}</Text>
      <Button onClick={() => calculate([2, 3, 5])}>Total 2, 3, and 5</Button>
      <Button onClick={() => calculate([])}>Try an empty array</Button>
    </Flex>
  )
}

Send a stream of inputs

The first example sends one array. This version sends separate { value: number } inputs and returns their total when the input stream ends. It replaces the previous total declaration and handler.

MyApp/MyApp+Flick.swift — replace MyApp.Action.total
@FlickAction(failure: Never.self, input: .stream, output: .effect(Int.self))
case total(value: Int) // One value per input, instead of one array per call
MyApp/ContentView.swift — replace the total handler
.taskForFlick(for: \.total) { values in
    var total = 0
    for await input in values {
        total += input.value
    }
    return total // Return once the input stream ends
}
Flick.tsx
import { Button, Flex, Text, useContextAction } from '@flickfyi/core'
import { Effect, Stream } from 'effect'
import { useState } from 'react'
import { MyApp } from 'com.acme.myapp'

export default function Total() {
  // (input: Stream.Stream<{ readonly value: number }>)
  //   => Effect.Effect<number>
  const total = useContextAction(MyApp.actions.total)
  const [result, setResult] = useState(0)

  return (
    <Flex>
      <Text>Total: {result}</Text>
      <Button onClick={async () => {
        const input = Stream.fromIterable([{ value: 2 }, { value: 3 }, { value: 5 }])
        setResult(await Effect.runPromise(total(input))) // 10
      }}>
        Total 2, 3, and 5
      </Button>
    </Flex>
  )
}

Receive a stream of results

A countdown takes one input, emits 3 immediately, then emits 2, 1, and 0 at one-second intervals. Add countdown alongside total and attach its handler to the same ContentView.

MyApp/MyApp+Flick.swift — add to MyApp.Action
@FlickAction(output: .stream(Int.self, failure: Never.self))
case countdown(from: Int)
MyApp/ContentView.swift — add a countdown handler
.taskForFlick(for: \.countdown) { input in
    // Int results; the stream cannot fail
    let (stream, continuation) = Flick.Stream<Int, Never, Never>.makeStream()
    // Return the stream immediately; this task produces values over time
    let task = Task {
        for value in stride(from: max(0, input.from), through: 0, by: -1) {
            guard !Task.isCancelled else { break }
            continuation.yield(value)
            if value > 0 {
                do {
                    try await Task.sleep(for: .seconds(1))
                } catch {
                    break
                }
            }
        }
        continuation.finish()
    }
    // Cancelling the consumer also stops the countdown
    continuation.onTermination = { _ in task.cancel() }
    return stream
}
Flick.tsx
import { Text, useContextAction } from '@flickfyi/core'
import { Effect, Fiber, Stream } from 'effect'
import { useEffect, useState } from 'react'
import { MyApp } from 'com.acme.myapp'

export default function Countdown() {
  // (input: Effect.Effect<{ readonly from: number }>)
  //   => Stream.Stream<number>
  const countdown = useContextAction(MyApp.actions.countdown)
  const [remaining, setRemaining] = useState(3)

  useEffect(() => {
    // Start consumption and keep its fiber so cleanup can cancel it
    const fiber = Effect.runFork(
      countdown(Effect.succeed({ from: 3 })).pipe(
        // Handle each value; sync wraps the React update in an Effect
        Stream.runForEach(value => Effect.sync(() => {
          setRemaining(value) // Update for each result, before the stream ends
        })),
      ),
    )
    // Unmounting cancels the call and stops the Swift countdown task
    return () => { Effect.runFork(Fiber.interrupt(fiber)) }
  }, [countdown])

  return <Text>{remaining}</Text>
}

Stream inputs and results

total waits for all its inputs before returning. runningTotal emits after each input: 2 → 2, 3 → 5, 5 → 10.

MyApp/MyApp+Flick.swift — add to MyApp.Action
@FlickAction(input: .stream, output: .stream(Int.self, failure: Never.self))
case runningTotal(value: Int)
MyApp/ContentView.swift — add a runningTotal handler
.taskForFlick(for: \.runningTotal) { values in
    let (stream, continuation) = Flick.Stream<Int, Never, Never>.makeStream()
    let task = Task {
        var total = 0
        for await input in values {
            total += input.value
            continuation.yield(total)
        }
        continuation.finish()
    }
    continuation.onTermination = { _ in task.cancel() }
    return stream // Consume input and produce output concurrently
}
Flick.tsx
import { Text, useContextAction } from '@flickfyi/core'
import { Effect, Fiber, Stream } from 'effect'
import { useEffect, useState } from 'react'
import { MyApp } from 'com.acme.myapp'

export default function RunningTotals() {
  // (input: Stream.Stream<{ readonly value: number }>)
  //   => Stream.Stream<number>
  const runningTotal = useContextAction(MyApp.actions.runningTotal)
  const [totals, setTotals] = useState<number[]>([])

  useEffect(() => {
    setTotals([])
    const input = Stream.fromIterable([{ value: 2 }, { value: 3 }, { value: 5 }])
    const fiber = Effect.runFork(
      runningTotal(input).pipe(
        Stream.runForEach(total => Effect.sync(() => {
          setTotals(current => [...current, total])
        })),
      ),
    )
    return () => { Effect.runFork(Fiber.interrupt(fiber)) }
  }, [runningTotal])

  return <Text>{totals.join(', ')}</Text> // 2, 5, 10
}

Handle a failing input stream

Here React sends one value, then fails with the TotalError declared above.

For this call from React, the input failure ends Swift's input sequence normally. The SDK returns that failure to React even if the Swift handler returns a total.

MyApp/MyApp+Flick.swift — replace MyApp.Action.total
@FlickAction(
    failure: TotalError.self, // Errors thrown by the Swift handler
    input: .stream(failure: TotalError.self), // Errors supplied by the Flick
    output: .effect(Int.self)
)
case total(value: Int)
MyApp/ContentView.swift — replace the total handler
.taskForFlick(for: \.total) { values async throws(TotalError) in
    var total = 0
    // try is required by the declared input-stream type
    for try await input in values {
        total += input.value
    }
    return total
}
Flick.tsx
import { Button, Flex, Text, useContextAction } from '@flickfyi/core'
import { Effect, Stream } from 'effect'
import { useState } from 'react'
import { MyApp, type TotalError } from 'com.acme.myapp'

export default function FailedInput() {
  // (input: Stream.Stream<{ readonly value: number }, TotalError>)
  //   => Effect.Effect<number, TotalError>
  const total = useContextAction(MyApp.actions.total)
  const [message, setMessage] = useState('Ready')

  return (
    <Flex>
      <Text>{message}</Text>
      <Button onClick={async () => {
        const input = Stream.concat(
          Stream.fromIterable([{ value: 2 }]),
          Stream.fail<TotalError>({ message: 'Input disconnected' }),
        )
        const result = await Effect.runPromise(
          total(input).pipe(
            Effect.match({
              onFailure: error => error.message,
              onSuccess: value => `Total: ${value}`,
            }),
          ),
        )
        setMessage(result) // Input disconnected
      }}>
        Send a value, then fail
      </Button>
    </Flex>
  )
}

Declaration defaults

Omitted optionDefault
No case arguments, with default input optionsCall action(); see Quick start
input: on a case with argumentsSupply the arguments in an Effect
Input failure:Never → never
output:One result: Void → void
Output failure:FlickContextError → ContextError with _tag and message

The generated result's failure type includes both input and output errors.

Publish state · Running Effects