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.
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[] }
}
}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, +)
}
}
}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.
import FlickSDK
// FlickObjectValue exposes message as a typed field in React
struct TotalError: Error, FlickObjectValue {
let message: String
}@FlickAction(failure: TotalError.self, output: .effect(Int.self))
case total(values: [Int]).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, +)
}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.
@FlickAction(failure: Never.self, input: .stream, output: .effect(Int.self))
case total(value: Int) // One value per input, instead of one array per call.taskForFlick(for: \.total) { values in
var total = 0
for await input in values {
total += input.value
}
return total // Return once the input stream ends
}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.
@FlickAction(output: .stream(Int.self, failure: Never.self))
case countdown(from: Int).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
}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.
@FlickAction(input: .stream, output: .stream(Int.self, failure: Never.self))
case runningTotal(value: Int).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
}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.
@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).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
}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 option | Default |
|---|---|
| No case arguments, with default input options | Call action(); see Quick start |
input: on a case with arguments | Supply 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.