How It's Made

What the Formula? Managing state on Android

State management is usually the most complex part of a UI application. At Instacart, we have been using RxJava to manage state for a few years now. As our Android team grew, we realized that there was a steep learning curve for new developers to get started. We started to experiment with how we could reduce complexity. After a few iterations, we are excited to announce Formula — an open-source state management library built in Kotlin. Formula provides a simple, declarative, and composable API for managing your app complexity. The goal is to express the essence of your program without much ceremony.

Building a simple app

Probably the easiest way to show you how Formula works is to build a simple stopwatch application. It will show how long the stopwatch has been running and allow the user to start/stop or reset it.

When working with Formula, the recommended way to start is by defining what the UI needs to display and what actions the user will be able to take. We model this with Kotlin data classes and call this concept a RenderModel. We model user actions as event listeners on the render model.

data class StopwatchRenderModel(
    val timePassed: String,
    val startStopButton: ButtonRenderModel,
    val resetButton: ButtonRenderModel
)

data class ButtonRenderModel(
    val text: String,
    val onSelected: () -> Unit
)

Render model is an immutable representation of your view. Any time we want to update UI, we will create a new instance and pass it to the RenderView. Render view is responsible for taking a render model and applying it to Android views.

class StopwatchRenderView(root: ViewGroup): RenderView<StopwatchRenderModel> {
    private val timePassed: TextView = root.findViewById(R.id.time_passed_text_view)
    private val startStopButton: Button = root.findViewById(R.id.start_stop_button)
    private val resetButton: Button = root.findViewById(R.id.reset_button)

    override val renderer: Renderer<StopwatchRenderModel> = Renderer.create { model ->
        timePassed.text = model.timePassed

        startStopButton.text = model.startStopButton.text
        startStopButton.setOnClickListener {
            model.startStopButton.onSelected()
        }

        resetButton.text = model.resetButton.text
        resetButton.setOnClickListener {
            model.resetButton.onSelected()
        }
    }
}

Note: we could refactor out a reusable ButtonRenderView.

With rendering logic out of the way, let’s see how we actually create the render model. Render model creation is the responsibility of the Formula interface. Let’s create a StopwatchFormula class that extends it.

class StopwatchFormula : Formula<Unit, StopwatchFormula.State, StopwatchRenderModel> {
    // We will use this a little later.   
    object State

    override fun initialState(input: Unit): State = State

    override fun evaluate(
        input: Unit,
        state: State,
        context: FormulaContext<State>
    ): Evaluation<StopwatchRenderModel> {
        return Evaluation(
            renderModel = StopwatchRenderModel(
                timePassed = "5s 10",
                startStopButton = ButtonRenderModel(
                    text = "Start",
                    onSelected = { /* TODO */ }
                ),
                resetButton = ButtonRenderModel(
                    text = "Reset",
                    onSelected = { /* TODO */ }
                )
            )
        )
    }
}

Formula interface takes three generic parameters: Input, State and Render Model (we will not use input in this example). In Formula, we keep all our dynamic properties within a single Kotlin data class which we call State. For the time being, we will use an empty State object as a placeholder. The evaluate function takes the current state and is responsible for creating the render model.

Our current implementation always returns the same render model. Before we make it dynamic, let’s connect this implementation to our render view. There is an extension function start that creates an RxJava observable from the Formula interface.

class StopwatchActivity : FragmentActivity() {

    private val disposables = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.stopwatch_activity)

        val renderView = StopwatchRenderView(findViewById(R.id.activity_content))
        val renderModels: Observable<StopwatchRenderModel> = 
            StopwatchFormula().start(Unit)
        disposables.add(renderModels.subscribe(renderView.renderer::render))
    }

    override fun onDestroy() {
        disposables.clear()
        super.onDestroy()
    }
}

For simplicity sake, I’ve placed this logic directly in the activity. In a real application, it should be placed within a surface that survives configuration changes such as AndroidX ViewModel or formula-android module.

Now that we are observing render model changes, let’s start making the UI dynamic. For example, when the user clicks the “Start” button, we want to reflect this change and update the button to show “Stop”. We need a dynamic property to keep track if the stopwatch was started. Let’s update our State to include isRunning. We initially set isRunning to false. If you have worked with Redux or MVI, this should look familiar to you.

class StopwatchFormula : Formula<Unit, StopwatchFormula.State, StopwatchRenderModel> {

    data class State(
        val isRunning: Boolean
    )

    override fun initialState(input: Unit): State = State(
        isRunning = false
    )
    
    ....
}

Let’s update the start/stop button render model creation.

fun startStopButton(state: State): ButtonRenderModel {
  return ButtonRenderModel(
    text = if (state.isRunning) "Stop" else "Start",
    onSelected = { /* TODO: toggle the stopwatch */ }
  )
}

Any time user clicks this button, we want to toggle isRunning and create a new render model. We will use FormulaContext to accomplish this.

fun startStopButton(state: State, context: FormulaContext<State>): ButtonRenderModel {
    return if (state.isRunning) {
        ButtonRenderModel(
            text = "Stop",
            onSelected = context.callback {
                transition(state.copy(isRunning = false))    
            }
        )
    } else {
        ButtonRenderModel(
            text = "Start",
            onSelected = context.callback {
                transition(state.copy(isRunning = true))
            }
        )
    }
}

FormulaContext is a special object passed by the runtime that allows us to create callbacks and define transitions to a new state. Any time onSelected is called we transition to a state where isRunning is inverted (if it was true it becomes false and vice-versa). Instead of mutating the state, we use the data class copy method to create a new instance. A transition will cause evaluateto be called again with the new state, new render model will be created, the Observable will emit the new value and our UI will be updated.

Now that we have a functioning button, we actually need to run the stopwatch and update the UI as time passes. Let’s add a new property to our state class.

class StopwatchFormula : Formula<Unit, StopwatchFormula.State, StopwatchRenderModel> {

    data class State(
        val timePassedInMillis: Long,
        val isRunning: Boolean
    )

    override fun initialState(input: Unit): State = State(
        timePassedInMillis = 0,
        isRunning = false
    )
    
    ....
}

The actual implementation to create a display value from milliseconds is a bit complex, so let’s just append ms to time passed.

fun formatTimePassed(timePassedInMillis: Long): String {
  // TODO: actually format it
  return "${timePassedInMillis}ms" 
}

To update timePassedInMillis we will use the RxJava interval operator.

Observable
  .interval(1, TimeUnit.MILLISECONDS)
  .observeOn(AndroidSchedulers.mainThread())

When the stopwatch is running, we want to listen to this observable and update timePassedInMillis on each event. To add this behavior we need to update evaluatefunction. As part of each state change, Formula can decide what services it wants to run and listen to. We update our Evaluation and use conditional logic within context.updates block to declare when we want our observable to run.

override fun evaluate(
    input: Unit,
    state: State,
    context: FormulaContext<State>
): Evaluation<StopwatchRenderModel> {
    return Evaluation(
        renderModel = ...,
        updates = context.updates {
            if (state.isRunning) {
                val incrementTimePassedEvents = RxStream.fromObservable {
                    Observable
                        .interval(1, TimeUnit.MILLISECONDS)
                        .observeOn(AndroidSchedulers.mainThread())
                }

                events(incrementTimePassedEvents) {
                    transition(state.copy(
                        timePassedInMillis = state.timePassedInMillis + 1
                    ))
                }
            }
        }
    )
}

Starting the stopwatch now updates the time passed label. The logic here is very simple: when isRunning is true, listen to incrementTimePassedEvents and increment timePassedInMillis. The updates mechanism is agnostic to RxJava so you need to wrap it using RxStream. The event callback is very similar to UI event callbacks except it’s already scoped so you don’t need to use context.callback.

We still need to handle the reset button clicks. This is the same as implementing the start/stop button. There are no new concepts to introduce here. Any time reset button is selected we transition to a new state where timePassedInMillis is set to 0 and isRunning is set to false.

fun resetButton(state: State, context: FormulaContext<State>): ButtonRenderModel {
    return ButtonRenderModel(
        text = "Reset",
        onSelected = context.callback {
            transition(state.copy(timePassedInMillis = 0, isRunning = false))
        }
    )
}

And we are done. You can find the source code here.

Learning More

There are still a lot of things that we didn’t go over such as composition, testing or side-effects. If you want to learn more, take a look at the documentation, samples or the Github repository. If you have any questions or feedback you can find me on Twitter.

Interested in projects like Formula? Check out Instacart’s current engineering openings here.

Thanks to Kaushik Gopal, François Blavoet, and Charles Durham.

Laimonas Turauskas

Author

Laimonas Turauskas is a member of the Instacart team. To read more of Laimonas Turauskas's posts, you can browse the company blog or search by keyword using the search bar at the top of the page.

Most Recent in How It's Made

One Model to Serve Them All: How Instacart deployed a single Deep Learning pCTR model for multiple surfaces with improved operations and performance along the way

How It's Made

One Model to Serve Them All: How Instacart deployed a single Deep Learning pCTR model for multiple surfaces with improved operations and performance along the way

Authors: Cheng Jia, Peng Qi, Joseph Haraldson, Adway Dhillon, Qiao Jiang, Sharath Rao Introduction Instacart Ads and Ranking Models At Instacart Ads, our focus lies in delivering the utmost relevance in advertisements to our customers, facilitating novel product discovery and enhancing…

Dec 19, 2023
Monte Carlo, Puppetry and Laughter: The Unexpected Joys of Prompt Engineering

How It's Made

Monte Carlo, Puppetry and Laughter: The Unexpected Joys of Prompt Engineering

Author: Ben Bader The universe of the current Large Language Models (LLMs) engineering is electrifying, to say the least. The industry has been on fire with change since the launch of ChatGPT in November of…

Dec 19, 2023
Unveiling the Core of Instacart’s Griffin 2.0: A Deep Dive into the Machine Learning Training Platform

How It's Made

Unveiling the Core of Instacart’s Griffin 2.0: A Deep Dive into the Machine Learning Training Platform

Authors: Han Li, Sahil Khanna, Jocelyn De La Rosa, Moping Dou, Sharad Gupta, Chenyang Yu and Rajpal Paryani Background About a year ago, we introduced the first version of Griffin, Instacart’s first ML Platform, detailing its development and support for end-to-end ML in…

Nov 22, 2023