Imagine you're using a todo app and add a new task. You hit "Add," but nothing happens for a few seconds. Then, after a delay, the task appears. Frustrating, right? In this post I will explore the idea of how we can use Optimistic updates to solve this problem within SAFE stack.
Optimistic Updates - What are they?
Whenever we do a create, update or delete operation in our web app, we may need to perform some api call which is most likely asynchronous and could take an uncertain amount of time. Optimistic updates are where we immediately update the client side state to represent the successful outcome of our operation as soon as we initiate the API call. Therefore our model represents an updated version of our client side state while our the server is processing the operation. Depending on what the server comes back with we can do 2 things: Do nothing with a successful response or in the case of an error/failure, roll back our client side state to whatever it was prior to the update in order to stay consistent with the actual source of truth, such as a database or cache.
Some API calls may be instantaneous, but not all. Without optimistic updates, your application may feel unresponsive, forcing users to wait for an operation on the server to finish before seeing the expected result on the front end. By assuming the action will succeed, optimistic updates improve UX, making things feel smoother.
Visual Comparison
We will use a todo app written with SAFE to demonstrate these ideas.
❌ Optimistic updates
(Here the user waits few seconds before they see their newly created todo appear in the todo list due to some long waiting api call)
✅ Optimistic Updates
(Here the user instantly sees their newly created todo appear in the todo list and later on is updated to reflect the value coming back from the api call.)
Breaking it down
Before diving in, it may help to read about
RemoteData
and theApiCall
type which is a part of ourSAFE.Meta
package: Dealing with Remote Data Using SAFE Client.>
Let's create a type to represent our Optimistic value:
type Optimistic<'T> = ('T * 'T option) option
The key part is the tuple ('T * 'T option)
, which represents (currentValue * previousValue)
. Since Optimistic is wrapped in Some
, currentValue
will always be present. However, previousValue
may be None
when there's no previous state. If we do need to roll back, we shift previousValue
into currentValue
and set previousValue
to None
.
Here's what our model looks like when we plug in the Optimistic
type into RemoteData
type Model = {
Todos: RemoteData<Optimistic<Todo list>>
Input: string
}
Let's assume we have an endpoint in our backend that deals with adding a new todo:
let addTodo todo =
// Simulates a call to an async source
System.Threading.Thread.Sleep(2000)
let markedTodo = {todo with Description = todo.Description + ": server"}
if Todo.isValid todo.Description then
todos.Add markedTodo
Ok()
else
Error "Invalid todo"
When the SaveTodo
message is triggered in the Elmish loop, we immediately update the todo list with the newly entered todo — essentially "jumping the gun" before the API responds. Once the API call completes, the Finished
case determines the final outcome: either confirming the update if successful or rolling back if it fails to keep our state consistent with the source of truth.
//...
| SaveTodo msg ->
match msg with
| Start todoText ->
// Initiate the api call to add the todo to the database
let saveTodoCmd =
let todo = Todo.create todoText
Cmd.OfAsync.perform todosApi.addTodo todo (Finished >> SaveTodo)
// Preemptively update the todos list by appending it with a newly created Todo from the input
// passed in
let newTodos =
model.Todos
|> RemoteData.map (Optimistic.updateWith (fun currentTodos -> currentTodos @ [ Todo.create todoText ]) [])
{
model with
Input = ""
Todos = RemoteData.startLoading newTodos ()
},
saveTodoCmd
| Finished todos ->
// We now received the API's response
{
model with
Todos =
match todos with
| Ok x -> model.Todos |> RemoteData.map (Optimistic.update x)
| Error e -> model.Todos |> RemoteData.map (Optimistic.rollback)
|> RemoteData.bind (fun x -> x |> RemoteData.Loaded)
},
Cmd.none
//...
What happens when there is a failed operation?
Let's introduce failure cases randomly using a coin flip in our backend handler. We simulate a delay to mimic real-world scenarios where a database or external source is involved. To help differentiate client and server values, we append ": server"
to the todo's description. (In real applications, this isn't necessary, as a successful response means no further updates are required.)
let addTodo todo =
// Simulate a call to an async source
System.Threading.Thread.Sleep(2000)
//++ random num gen
let random = System.Random()
let markedTodo = {todo with Description = todo.Description + ": server"}
//++ flip of a coin error instigator
if Todo.isValid todo.Description && random.Next(2) = 0 then
todos.Add markedTodo
Ok()
else
Error "Invalid todo"
We use the rollback
helper function to move the previous value as the new current value
/// Rolls back to the previous value
let rollback (optimistic: Optimistic<'T>): Optimistic<'T> =
match optimistic with
| None -> None
| Some (_, Some pv) -> Some (pv , None)
| Some (_, None) -> None
(Here the user instantly sees their newly created todo appear in the todo list but vanishes after few seconds due to an invalid todo failure from the server)
Conclusion
Optimistic updates greatly enhance UX by making interactions feel instant while keeping the application responsive. Consider using them in your SAFE Stack apps!
Further investigation could include handling multiple back-to-back operations and how rollbacks should behave in such cases (perhaps using a queue mechanism?)
If you’d like to dive deeper and experiment with this implementation, check out the demo repo on GitHub.