Skip to main content
SwiftUI for Jetpack Compose developers - State (I)

SwiftUI for Jetpack Compose developers - State (I)

·5023 words·24 mins
Also, Jetpack Compose for SwiftUI developers

Over the past year I’ve been playing around a lot of multiplatform toolkits, primarily Kotlin Multiplatform, which has allowed me to explore writing mobile UIs on platforms other than Android. I’ve often found myself knowing how to model or implement something in Jetpack Compose, but struggling to quickly translate it to other UI toolkits. This blog series has been written as a self-reference for exactly that, hopefully it is useful for others too!

As I’ve been an Android developer for many years, these blog posts will naturally bias towards being a reference for Compose developers to write SwiftUI. However I have tried not to assume any knowledge, so hopefully they will be just as useful for SwiftUI developers learning Compose UI too. Let me know if not!

The series will cover some of the core concepts in declarative and reactive UI programming, and how they are implemented in both Compose UI and SwiftUI, such as:

  • Units of UI
  • State
  • Side-effects
  • Building components
  • Controlling UI
  • Animations

Each section will introduce the equivalent concepts in each toolkit, with links to further reading material if you want to go deeper. If there’s something fundamental which you think is missing, please let me know!

In this first post, we’ll be covering the more fundamental concepts of state handling, which is probably the most important thing to know when starting to use a reactive UI framework.

I expect that these posts will be updated numerous times. I’ll add a changelog at the bottom if anything changes.

Background
#

SwiftUI and Compose UI are both modern UI toolkits designed to make UI development more straightforward, effective, and enjoyable. They both model UIs declaratively, allowing developers to ‘describe’ the UI as a transform of state, with the underlying system responsible for reacting to state changes and updating the UI.

SwiftUI can be used on iOS, MacOS, WatchOS, and TvOS, using the Swift programming language. SwiftUI is baked into the OS, meaning that the functionality which is available to us as developers changes based on what device it is running on.

These blog posts will take a pragmatic approach of discussing SwiftUI APIs which are available in iOS 15+. There are a bunch of new APIs in iOS 16/17, which I will call out, but I haven’t used yet to talk about.

Compose’s story is a little more complex. The Android team at Google created Jetpack Compose, and released the first version in 2020. Jetpack Compose can be used for apps on all Android devices (phones, foldables, tablets, watches, TVs), Chrome OS, as well as integrations into Android such as notifications. Jetpack Compose is unbundled, meaning that developers bundle all of the necessary bits into their apps. This enables developers to be (reasonably) sure that any thing which runs on the latest version of Android should work on old versions too.

Now comes the complex bit: JetBrains, the creators of Kotlin, soft-forked Jetpack Compose to create Compose Multiplatform. This ‘fork’ adds in support for extra platforms, such as iOS, Desktop (JVM) and Web, building on top of Kotlin Multiplatform. Since both Jetpack Compose and Compose Multiplatform are practically the same thing (in terms of programming model and APIs) I’ll refer to them collectively as Compose UI for the rest of this blog post.

So let’s get started with the concepts.

Units of UI
#

The first thing we’ll look at it is, what are the basic buildings blocks of creating UI? SwiftUI and Compose UI actually differ here quite a bit, so we’ll just cover the basics.

Compose UI
#

In Compose UI, the basic building block of creating UI is a ‘composable’ function:

@Composable // this annotation marks the function as composable
fun MyFancyUi() {
    // Text() is a function too!
    Text("Hi everyone!")
}

A ‘composable’ function is one which the underlying system will automatically observe state which the function reads (more on state later). When an observed state changes, the system re-invokes the function to reflect the new state values. This is the basic mechanism of what Compose calls ‘recomposition’: the function is ‘recomposed’ (re-invoked).

Since this is a function, we don’t have an obvious place to be able to record values and reuse them across function calls. That’s where memoization comes into play. Compose has built-in memoization through its remember functions, allowing you to remember values across function calls. You can even key them if required.

@Composable
fun MyFancyUi() {
    val id = remember { randomId() }
    Text("Hi user-$id!")
}

In effect, this means that the first time the function is called, a random id will be stored. After that, each MyFancyUi call will use the same id value.

It’s actually a little more complicated than that, as the Compose runtime will compare the call stack to disambiguate MyFancyUi calls from different parts of your UI. This is beyond the scope of this blog post though. For more information on this, the Compose Runtime, Demystified talk by Leland Richardson is a great starting point.

SwiftUI
#

In SwiftUI, the basic unit of UI is a View, which is a protocol (interface) which you implement in a struct. The protocol contains a single property: your body content:

struct MyFancyView: View {
    var body: some View {
        Text("Hi everyone!")
    }
}

Since our views are types, rather than functions, we need to create instances of our views, and therefore have an obvious place to store any values. This means that SwiftUI does not need a direct equivalent to Compose’s remember functions, as you can store them in the view instead:

struct MyFancyUi: View {
    private var id: Int = Int.random(in: 0..<1000)

    var body: some View {
        Text("Hi user-\(id)!")
    }
}

State
#

State is fundamental to a reactive and declarative UI toolkit, as we mentioned earlier, the resulting UI is the result of a function over state. This section is the majority of this blog post, and where I spent the longest when learning SwiftUI.

Creating state
#

The first thing we will look at is how to create state.

Compose UI
#

In Compose UI, we have the State and MutableState interface, which are what you will use 99% of the time. There are builder functions available, allowing you to create a mutable value holder. The common one you’ll use is mutableStateOf.

Any (Mutable)State instances read in a composable function are automatically observed, so that the function can be automatically re-invoked when the state changes. Since we’re using a holder around our value, we need to make sure that we use the same instance across function calls. This is where remember is needed again:

@Composable
fun MyFancyUi() {
    // We remember the same MutableState instance across calls
    val clickCount = remember { mutableStateOf(0) }

    // We'll increment clickCount's value on each click (aka tap)
    // This will trigger a recomposition...
    Surface(onClick = { clickCount.value = clickCount.value + 1 }) {
        // ...and display the new clickCount value
        Text("Click count: ${clickCount.value}")
    }
}

There are quite a few different mutable state factory functions in Compose, for primitive types (Int, Float, Long, etc), maps, and lists, therefore use the one which makes most sense.

For those unfamiliar with Kotlin syntax, there’s also a shortcut to referencing and updating State values, using property delegates:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

// 👆 These are property delegates imports, allowing us to
// unwrap State instances automatically

Composable
fun MyFancyUi() {
    // Note: the `by` keyword
    val clickCount by remember { mutableStateOf(0) }

    // We can now reference `clickCount` directly as a value
    Surface(onClick = { clickCount = clickCount + 1 }) {
        Text("Click count: $clickCount")
    }
}

In terms of what runs on devices, the two examples above are bit identical, with property delegates being the most commonly used (less code 😅). Use whichever you feel more comfortable with though.

SwiftUI
#

In SwiftUI, state is created through Swift property wrappers which are bundled in SwiftUI. Which you use depends on what sort of data you’re storing.

Property wrappers in Swift are similar to property delegates in Kotlin.

In Kotlin, everything is either a primitive (Int, etc), or a reference type. In Swift however, you have the choice of using a value type (structs), or reference type (classes). We won’t delve too deep into this topic in this blog post, but the difference is key when we think about detecting changes.

The simplest SwiftUI property wrapper is @State, which is primarily used with value types. Whenever the values changes, SwiftUI will trigger a rebuild:

struct MyFancyUi: View {
    @State private var clickCount: Int = 0

    var body: some View {
        // We'll increment clickCount on each tap (aka click)
        Text("Click count: \(clickCount)")
            .onTapGesture {
                self.clickCount += 1
            }
    }
}

We can also encapsulate clickCount in a struct, and it would continue to work:

struct MyFancyUi: View {
    @State private var state: MyFancyState = .init()

    var body: some View {
        // We'll increment clickCount on each tap (aka click)
        Text("Click count: \(state.clickCount)")
            .onTapGesture {
                state.clickCount += 1
            }
    }
}

struct MyFancyState {
    var clickCount: Int = 0
}

So we know that @State will work for value types, but what about reference types? If we change the example above to be a class MyFancyState rather than a struct, we will no longer see any rebuilds. Incrementing clickCount in a class will not result in a new MyFancyState, therefore the @State’s value remains the same, and no rebuild.

So how can we trigger rebuilds from class value changes? We can make our classes implement the ObservableObject protocol, and use @Published on any observable properties:

class MyFancyState: ObservableObject {
    @Published var clickCount: Int = 0
}

On the calling side, we need to swap out the @State property wrapper for an @StateObject:

class MyFancyState: ObservableObject {
    @Published var clickCount: Int = 0
}

struct MyFancyUi: View {
    // Use @StateObject instead
    @StateObject private var state: MyFancyState = .init()

    var body: some View {
        // same as above
    }
}

This will ensure that our MyFancyState object is created once during the lifetime of the view which created it (even if the struct is recreated). This achieves a similar memoization to Compose’s remember.

So far we’ve looked at state created within the view, but what if the object is created externally? Let’s add a child view to our example to explore that:

class MyFancyState: ObservableObject {
    @Published var clickCount: Int = 0
}

struct MyFancyUi: View {
    // This view is creating the object, so we use @StateObject to scope the
    // lifetime to this view
    @StateObject private var state: MyFancyState = .init()

    var body: some View {
        MyFancyUiChild(state: state)
    }
}

struct MyFancyUiChild: View {
    // Use @ObservedObject to observe an externally created ObservableObject
    @ObservedObject var state: MyFancyState

    var body: some View {
        MyFancyUi
    }
}

Further reading
#

Binding state
#

In the examples above, we have relied on lambdas/closures to know when values have changed, but both platforms use different patterns for more complex state changes.

SwiftUI
#

Let’s switch up our example and allow the user to input some text. In SwiftUI we can use the TextField view:

struct MyTextInput: View {
    @State private var name = ""

    var body: some View {
        Form {
            TextField("Enter your name", text: /* todo */)
            Text("Hi \(name)!")
        }
    }
}

We’re using a @State string to store our name, along with a TextField to gather user input, and a Text to display whatever the user inputs. How do we receive the input changes from the text field?

If we look at the TextField docs, the text parameter has a type of Binding<String>, what is that? 🤔. SwiftUI uses bindings extensively, as the primary way to support two-way binding to some other source of truth.

Going back to our example, we could create our own binding which just gets/sets our name state like so:

struct MyTextInput: View {
    @State private var name = ""

    var body: some View {
        Form {
            TextField(
                "Enter your name",
                text: Binding { // get
                    name
                } set: { value in
                    name = value
                }
            )

            Text("Hi \(name)!")
        }
    }
}

Luckily SwiftUI makes this easy, as property wrappers can be projected as Bindings using a special syntax: prefixing the property name with a $:

struct MyTextInput: View {
    @State private var name = ""

    var body: some View {
        Form {
            TextField(
                "Enter your name",
                // Note the $ before the property name. 
                // The compiler generates a binding for us and we're using it
                text: $name 
            )

            Text("Hi \(name)!")
        }
    }
}

Compose
#

In Compose, there isn’t a specific way to bind-data like in SwiftUI. Compose leans heavily into Kotlin language features, rather than creating its own constructs, to allow developers to bind state in a toolkit-agnostic way(ish).

If we go back to the text field example above, in Compose UI we can do something like this:

@Composable
fun MyTextInput() {
    // A mutable state to store the current value
    var name by remember { mutableStateOf("") }

    Column {
        TextField(
            // Pass in the current value
            value = name,
            // Update our name state when the user inputs some text
            onValueChange = { name = it },
            label = {
                Text("Enter your name")
            },
        )

        Text("Hi $name!")
    }
}

TextField in Compose UI (actually in Material3) is a good example of state hoisting (more on this later), relying on fundamental language constructs, value passing and lambda callbacks, to know when a value changes. This relies heavily on the Compose runtime recomposing, so that state changes are reflected throughout the hierarchy.

Since we are using Kotlin property delegates, we can even mimic SwiftUI bindings by de-structuring the property, to the current value and setter like so:

@Composable
fun MyTextInput() {
    // name = current value, setName = (String) -> Unit lambda
    val (name, setName) = remember { mutableStateOf("") }

    Column {
        TextField(
            // We can then pass in the destructured delegates:
            value = name,
            onValueChange = setName,
        )
    }
}

Whether you use this pattern is a question of taste and style. I’ve not seen this pattern used so much in real-world Compose development, but it is there if you prefer it.

Further reading
#

Lifting state up (state hoisting)
#

Lifting state up (aka state hoisting) is one of the most common things you’ll do when using a reactive UI framework. It’s a term which has been around since React (web) gained adoption (possibly before?).

If you’d like to know more, the React docs have a detailed page on sharing state:

Sharing State Between Components – React
The library for web and native user interfaces
react.dev

I personally like the description from the Jetpack Compose Basics codelab:

State that is read or modified by multiple functions should live in a common ancestor—this process is called state hoisting. Making state hoistable avoids duplicating state and introducing bugs, helps reuse [UI], and makes [UI] substantially easier to test.

As both SwiftUI and Compose UI are reactive frameworks, you will find yourself needing to regularly hoist state when using either of them.

SwiftUI
#

As we learned earlier, state in SwiftUI is created using one of the SwiftUI property wrappers on a property. This means that we can’t pass around a State instance, as the wrapper is hidden from us.

We’ve already looked at how SwiftUI supports state hoisting: Binding, but so far we’ve only looked at binding child views to our state. How do we create our own components and allow them to be expect incoming state? The @Binding property wrapper.

The @Binding property wrapper is another property wrapper provided by SwiftUI, and this one basically says: ‘defer reading and writing to some other source of truth’.

Let’s use update our text field sample so that the TextField is extracted to a reusable view:

struct MyTextInput: View {
    @State private var name = ""

    var body: some View {
        Form {
            FancyTextField(input: $name)

            Text("Hi \(name)!")
        }
    }
}

struct FancyTextField: View {
    // We use the Binding property wrapper allowing parents
    // to bind their own state (or bindings)
    @Binding var input: String

    var body: some View {
        TextField(
            "Enter your name",
            // Same as before: we use a generated binding which delegates
            // to our @Binding
            text: $input
        )
    }
}

It’s bindings all the way down in SwiftUI, so get ready to use them a lot.

Compose
#

Similar to the ‘Binding State’ section above, Compose leans heavily in to language constructs, and hoisting state is no different. Typically when hoisting state in Compose, you will expose a value: Foo parameter (current value), and a onValueChange: (Foo) -> Unit lambda parameters as a callback for value changes. The caller is then responsible for either updating its state, or proxying the change up.

Let’s update our text field sample so that the TextField is extracted to a reusable composable:

@Composable
fun MyTextInput() {
    var name by remember { mutableStateOf("") }

    Column {
        FancyTextField(
            input = name, 
            onValueChange = { name = it }
        )

        Text("Hi $name!")
    }
}

/**
 * Our FancyTextField composable exposes two parameters:
 * - `input` is the current value
 * - `onInputChange` a callback for when the value has changed
 */
@Composable
fun FancyTextField(input: String, onInputChange: (String) -> Unit) {
    TextField(
        value = input,
        onValueChange = {
            onInputChange(it)
        },
        label = { Text("Enter your name") }
    )
}

By leaning into language constructs such as lambdas and value passing, this enables your composables to be decoupled, encapsulated and thus easily reusable. Doing this usually results in the composable being stateless, resulting in it being more reusable and easier to test. The Jetpack Compose State docs go deeper into the benefits here.

As we mentioned earlier, state in Compose is an object which you create through one of the provided factory functions ( mutableStateOf, etc). This means that you could mimic SwiftUI’s bindings by passing around your MutableState<Foo> instance around:

@Composable
fun MyTextInput() {
    var name = remember { mutableStateOf("") }

    Column {
        FancyTextField(input = name)

        Text("Hi $name!")
    }
}

/**
 * ⚠️ Don't do this. ⚠️
 */
@Composable
fun FancyTextField(input: MutableState<String>) {
    TextField(
        value = input.value,
        onValueChange = {
            // Update the provided MutableState directly
            input.value = it
        },
    )
}

However, this is an anti-pattern in Compose UI, as outlined by the Compose UI API Guidelines:

When a component accepts MutableState as a parameter, it gains the ability to change it. This results in the split ownership of the state, and the usage side that owns the state now has no control over how and when it will be changed from within the component’s implementation.

Environmental state
#

There are times when manually passing state or objects between different pieces of UI is either impractical or just repetitive. In these instances, both frameworks support the idea of creating ‘environmental’ or ‘global’ state, which can be referenced from anywhere within the UI hierarchy. Common examples of global state include: theming, external factories, formatters, etc.

The way Compose UI and SwiftUI handle these are actually very similar.

It’s easy to overuse environmental state, leading to coupled & brittle code. Most of the time there are usually better alternatives, such as passing values, or using the ‘inversion of control’ pattern. I haven’t found any SwiftUI guidance on this topic, but the Compose docs show some best practices, and are generally applicable to both frameworks.

Compose
#

In Compose, environmental state is stored in what is called CompositionLocals. The general idea is that you create a CompositionLocal, which acts as a key within the internal composition state. You can reference the local at any time, and get the current value to use as required.

There are two factory functions for creating a composition local: compositionLocalOf and staticCompositionLocalOf. I’ll leave learning the difference between the two as an exercise for the reader, but for now just know that using them is exactly the same, the difference is primarily for performance. We’ll use compositionLocalOf for the examples below, but we could use staticCompositionLocalOf instead and it would work the same.

So let’s create a composition local:

val LocalFancyDateFormatter = compositionLocalOf {
    // This is the default value. It could also be null
    FancyDateFormatter(...)
}

Here we are creating a composition local to store a FancyDateFormatter instance, but how do we use it?

Let’s create a new example, this time allowing the user to select a date, and then display the date:

@Composable
fun MyDatePicker() {
    var date by remember { mutableStateOf<LocalDate?>(null) }

    Column {
        DatePicker(
            date = date, 
            onDateChange = { date = it }
        )

        // Here's we displaying the date using it's toString().
        // This works, but isn't particularly readable. We should
        // format the date in some way 🤔
        Text("Selected date: $date")
    }
}

As we now have our FancyDateFormatter available, we can use that in our composables:

@Composable
fun MyDatePicker() {
    var date by remember { mutableStateOf<LocalDate?>(null) }

    Column {
        // DatePicker ommitted...

        // Grab the current FancyDateFormatter from the 
        // composition local. From here, we can just use it
        // as normal...
        val formatter = LocalFancyDateFormatter.current
  
        Text("Selected date: ${formatter.format(date)}")
    }
}

So far we’ve only looked at using the default value of a CompositionLocal, but you can also override the value so that any callers from the subtree get that value instead. To do that, you use the CompositionLocalProvider function, providing the new value to it.

It’s easier to show this with a contrived example:

val LocalCount = compositionLocalOf { 0 }

@Composable
fun A() {
    CountText() // This will display 0

    // Here we override LocalCount to 1. Any composables from within
    // the block (recursively) will see LocalCount.current return 1.
    CompositionLocalProvider(LocalCount provides 1) {
        B()
    }
}

@Composable
fun B() {
    // This will display 1, as we're being called within the 
    // CompositionLocalProvider above
    CountText()
}

@Composable
private fun CountText() {
    // Just displays the current LocalCount value
    Text("${LocalCount.current}")
}

SwiftUI
#

In SwiftUI, environmental state is stored in what is called EnvironmentValues. They work very similar to CompositionLocal in Compose, except that the key and holder are separated.

So let’s create some environment state to store a date formatter:

struct FancyDateFormatterKey: EnvironmentKey {
    static let defaultValue: FancyDateFormatter = FancyDateFormatter(...)
}

extension EnvironmentValues {
    var fancyDateFormatter: FancyDateFormatter {
        get { self[FancyDateFormatterKey.self] }
        set { self[FancyDateFormatterKey.self] = newValue }
    }
}

Let’s create a new example, this time allowing the user to select a date, and then display the date:

struct MyDatePicker: View {
    @State private var date: Date?

    var body: some View {
        VStack {
            DatePicker("Select a date", selection: $date)

            // Here's we displaying the date using it's string representation.
            // This works, but isn't particularly readable. We should
            // format the date in some way 🤔
            Text("Selected date: \(selectedDate)")
        }
    }
}

As we now have our FancyDateFormatter available, we can use that in our view. To reference the the environmental value, we can use the @Environment property wrapper, providing the necessary key:

struct MyDatePicker: View {
    @State private var date: Date?

    // This will be set to the current fancyDateFormatter value
    @Environment(\.fancyDateFormatter) private var fancyDateFormatter

    var body: some View {
        VStack {
            DatePicker("Select a date", selection: $date)

            // We can then use it as necessary
            Text("Selected date: \(fancyDateFormatter.format(date))")
        }
    }
}

So far we’ve only looked at using the default value of an environmental value, but you can also override the value so that any views in the subtree get that value instead. To do that, you use the environment view modifier, passing the new value to it:

It’s easier to show this with a contrived example:

struct CountKey: EnvironmentKey {
    static let defaultValue: Int = 0
}

extension EnvironmentValues {
    var count: Int {
        get { self[CountKey.self] }
        set { self[CountKey.self] = newValue }
    }
}

struct AView: View {
    var body: some View {
        VStack {
            // This will display count = 0, as CountText will reference the
            // default value
            CountText()

            // Here we override the environment of BView, so that count = 1.
            // BView and any descendant views will see the count value set to 1
            BView()
                .environment(\.count, 1)
        }
    }
}

struct BView: View {
    var body: some View {
        // This will display 1, as our environment contains a value
        //  of count = 1
        CountText()
    }
}

struct CountText: View {
    @Environment(\.count) private var count

    var body: some View {
        // Display the current count value from the environment
        Text("\(count)")
    }
}

Side effects
#

We’ve looked at state, but what happens when you need to react to a change originating from outside the UI? This is where side effects are useful.

Run asynchronous code
#

There are times when you need to run some asynchronous code from your UI, and scope it to the appropriate lifecycle of that piece of UI. Both Compose UI and SwiftUI provide easy ways to run asynchronous code, appropriate for the underlying language features of the framework.

Compose
#

As Compose UI is written in Kotlin, it makes heavy usage Kotlin Coroutines to support asynchronous work. There are two main mechanisms for launching coroutines from Compose UI: LaunchedEffect and rememberCoroutineScope.

LaunchedEffect
#

Let’s start with the most commonly used: LaunchedEffect. As the name suggests, this is a side-effect which is launched within a scoped coroutine. In this example we’re going to launch a coroutine to collect a Kotlin Flow (observable):

class MovieViewModel {
    val flow: Flow<MovieViewState>
}

@Composable
fun MoviesScreen(viewModel: MovieViewModel) {

    // Create a state to store our current view state
    var viewState by remember { mutableStateOf<MovieViewState?>(null) }

    // Launch a coroutine to collect the ViewModel's Flow
    LaunchedEffect(viewModel) {
        viewModel.flow.collect {
            // Once we get a new ViewState, update our viewState
            viewState = it
        }
    }

    // rest of screen can use viewState
}
Note: there are built-in functions to collect flows, so this example won’t strictly be needed.

LaunchedEffect supports automatic restarting via its key parameters. If any of the keys change after a recomposition, the existing coroutine is cancelled, and a new one started.

Related to LaunchedEffect is the produceState factory function. That uses LaunchedEffect internally, to create a higher-level function enabling easier transforming of external state into Compose state.

rememberCoroutineScope
#

The second mechanism is rememberCoroutineScope. This is a lower level primitive in Compose UI, allowing you to manually launch your own coroutines, automatically scoped to the lifetime of the composition.

Typically you would use this when need to call suspending functions from synchronous callbacks. In this example we have a button which allows the user to refresh the UI:

class MovieViewModel {
    suspend fun refresh()
}

@Composable
fun MoviesScreen(viewModel: MovieViewModel) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Column {
        RefreshButton(
            onClick = {
                // onClick is synchronous, but refresh() is suspending so we
                // can't call it directly. Instead we launch a coroutine from
                // our scope
                scope.launch {
                   viewModel.refresh()
                }
            }
        )

        // rest of screen
    }
}

SwiftUI
#

Very similar to Compose UI, SwiftUI also has similar concepts, using the async await system in Swift. There are two main mechanism in SwiftUI for executing async functions:

Task.init
#

When you need to invoke an async function from synchronous code, you can create a new Task and invoke the async function within it. In this example we have a button which allows the user to refresh the UI:

class MovieViewModel: ObservableObject {
    func refresh() async {
        // todo
    }
}

struct MoviesScreen: View {
    @StateObject private var viewModel = MovieViewModel()

    var body: some View {
        VStack {
            RefreshButton {
                // this is the synchronous onClick. To invoke the async
                // refresh function we need to run it in a Task. This task
                // is attached to the outer task
                Task {
                    await viewModel.refresh()
                }
            }

            // rest of screen
        }
    }
}
Note: the created Task does inherit some context from the caller (such as the Actor), but it is not scoped to the lifecycle of the view. The task created is a ‘top-level task’ and thus will keep on running if it isn’t manually cancelled (and doesn’t finish).
task view modifier
#

So what if we need to run an async function scoped to the lifecycle of the view? (which is nearly always the case). That is the where the task view modifier is useful:

class MovieViewModel: ObservableObject {
    let viewState: AsyncSequence<MovieViewState>
}

struct MoviesScreen: View {
    @StateObject private var viewModel = MovieViewModel()

    @State private var viewState: MovieViewState

    var body: some View {
        VStack {
            // rest of screen, referencing viewState
        }
        .task {
            // This Task will be scoped to the View being visible.
            // As soon as the view is removed, the task will be cancelled
            for await viewState in viewModel.viewState {
                self.viewState = viewState
            }
        }
    }
}
Note: this example isn’t a ‘real world’ example of ViewModels in SwiftUI. SwiftUI developers tend to prefer exposing SwiftUI @Published properties from ViewModels. This in contrast to most Compose developers, who keep Compose primitives out of the ViewModel, using a Flow as the observable.

Conclusion
#

Hopefully this post has been useful in seeing how two reactive UI toolkits are both similar, but also different, in how they handle the most important thing in a reactive UI toolkit: state. If there’s anything useful you think is missing from this post, please let me know on socials and I’ll see what I can do in adding it.

This was the first post of two (maybe more). In the next post we’re take a look at the UI side of the frameworks, and how to actually get things measured, laid out and drawn onto screen.

Acknowledgements
#

  • 🙌 Thanks to Nacho López and Dai Williams for reviews.
  • 🙌 Thanks to Landon Epps for the pointers about StateObject.
  • 🦾 Thanks to ChatGPT for speeding up writing the equivalent samples (it’s scary how good it is at this).