Learning Remix: Optimistic UI

Let’s be clear about one thing before we get started: optimistic UI has been around for ages. It’s not tied to Remix in any way, and you can create optimistic UI in any framework in any language.

But, Remix makes it a lot easier to create optimistic UI than most other frameworks.

In fact, we have most of what we need in place already, we only need to tweak things a bit.

We’ll start with the task list. Right now we make a task transparent while we wait for the resolution of the Promise we get from deleteTask().

We can change this to removing it from the list. We don’t even need to actually remove it, it’s enough to fake it. We’re talking about optimistic UI, not optimistic deletion.

Take a look in TodoTask.tsx, and the div that wraps the entire component.

Right now we’re doing this:

<div key={task.id} style={{opacity: isDeleting ? 0.25 : 1}} >

Now, we can do this instead:

<div key={task.id} style={{display: isDeleting ? "none":"block"}} >

Now, it will look like we delete the task immediately when we click the delete button. Perfect.

Or not.

What happens if there is an error? What if the database is unreachable? What then?

As it is now, the task will reappear, and the user won’t understand why. That’s bad. And easy to fix.

We need to check if there’s an error in the return from fetcher. Then we can change the delete button to something else. Something to reflect that things went wrong and that the user needs to try again.

Let’s add this, after the definition of isDeleting:

let isFailedDeletion = fetcher.data?.error;

If there is an error, we’ll get that from the fetcher, and we can use that to style the button:

<button type="submit" name="_action"
	aria-label= {isFailedDeletion ? "Retry":"delete"}
	value="delete" > {isFailedDeletion ? "Retry" : "×"}
</button>

Notice that we also add an aria-label to reflect the change for people with screen readers.

To test that this works, you want to do two things. First throttle your browser to simulate “Slow 3G”. Second, make the deletion fail.

You can make the deletion fail by throwing an error in your action in your Index.tsx, in the branch for delete:

export async function action({ request }:any) {
  let formData = await request.formData();
  const {_action, ...values} = Object.fromEntries(formData);
  if(_action === "create"){
    createTask(values as ITask);
  }
  if(_action=== "delete") {
    try {
      throw new Error("Boom");
      let ID = parseInt(values.ID);
      await deleteTask(ID);
      } catch (e) {
        return {error: true};
      }
  }
return {};
}

This takes care of the delete part, let’s turn to create.

This will need a little more work, but not much. We need to change the interface Props. It has to accommodate an optimisticallyAdded property. Then we add the ”fake” added task below the ”real” ones.

Let’s start with TodoTask and the Props interface. Add an optional property called optimisticallyAdded:

interface Props {
  task: ITask;
  optimisticallyAdded?: boolean;
}

The question mark between the property name and the colon makes it an optional property.

We want to disable the delete button for an optimistically added task. Add the disabled attribute to it and make it equal to optimisticallyAdded:

<button disabled={optimisticallyAdded} type="submit" name="_action" aria-label={isFailedDeletion ? "Retry":"delete"} value="delete">
	{isFailedDeletion ? "Retry" : "×"}
</button>

Let’s also create two new bindings for a task name and deadline for the optimistically added task:

let optimisticTask: string = transition.submission?.formData.get("task")?.toString() as string;
let optimisticDeadline: string = transition.submission?.formData.get("deadline")?.toString() as string;

Now, all we need to do is add the extra task whenever isAdding is true. This is what it’ll look like:

<div>
	{todoList.map((task: ITask, key: number) => {
    return <TodoTask key={key} task={task} />;
  })}
  {isAdding && (
	  <TodoTask key="extra" optimisticallyAdded={true}
	  task={{task: optimisticTask,
	  deadline: optimisticDeadline }}
	  />
  )}
</div>

On a slow network, this will look a little weird, the task gets added with a disabled button. Then it goes away, only to return with an enabled button.

Which is better, optimistic UI that might look weird on slow networks, or pending UI? You decide, but my preference is the pending UI, at least for the moment. YMMV.

Right now, everything works but looks awful. Let’s bring in Tailwind and fix that in the next part of this series.

About the author

For the last three decades, I've worked with a variety of technologies. I'm currently focused on fullstack development. On my day to day job, I'm a senior teacher and course developer at a higher vocational school in Malmoe, Sweden. I'm also an occasional tech speaker and a mentor. Do you want to know more? Visit my website!