DEV Community

Cover image for Optimistic UI with Remix: The Game-Changer You Need to Implement Now
ishan
ishan

Posted on

Optimistic UI with Remix: The Game-Changer You Need to Implement Now

Optimistic UI or commonly also known as ‘Pending UI’ has been around for ages. We can create an optimistic UI in any framework or any language. Libraries like TanStack Query and SWR provide the framework for developers to implement optimistic updates when working with the React ecosystem.

But, Remix makes it a lot easier to implement an optimistic UI than most other frameworks out there in the wild.

Optimistic UI is a powerful pattern that makes your web applications feel incredibly fast and responsive. Instead of waiting for a server confirmation, the UI updates immediately after a user action, assuming the action will succeed. If the server does confirm success, great! If it fails, the UI gracefully reverts to its previous state, often with an error message.

Unlike traditional client-heavy frameworks, Remix embraces native browser features such as forms, requests, and responses, enabling developers to ship faster, more resilient applications. Many React-based frameworks rely on useState for managing pending UI during network interactions, Remix simplifies this with useNavigation and useFetcher.

⏳ Types of Fallback UI's

We can separate out Optimistic update, Pending UI or Optimistic UI into three main logical categories.

  • Skeleton fallbacks: Displays a visual placeholder outlining the structure of upcoming content. It's typically used when loading non-critical data for initial page rendering.

  • Busy indicators: Shows a visual sign while an action is being processed by the server. It's used when the outcome is uncertain, requiring a wait for the server's response before updating the UI.

  • Optimistic UI: Updates the UI with an expected state before receiving the server's response. It's used when the outcome can be reliably predicted, enabling immediate feedback.

These are the cases where we make a mutation to our data and wait for the server to provide us a response that the mutation has succeeded. Adding predictability behaviour inside our application for a better resilient and sleek user experience.

In this post, we'll dive into implementing Optimistic UI in a Remix.run application, combined with Mock Service Worker(MSW) for robust API mocking and intercepting API requests into our application. Essential for development and testing. It allows us to intercept network requests and define mock responses right in the browser, simulating API behavior without needing a real backend running. This is invaluable for rapid development and testing edge cases like API failures.

💪 Project Setup and File structure

npx create-remix@latest my-fruits-app 
cd my-fruits-app
Enter fullscreen mode Exit fullscreen mode

We will stick with a simple barebones file structure for our application. Which might look something like this.

my-optimistic-fruits-app/
├── app/
   ├── routes/
      └── _index.tsx  (Our main component for displaying fruits)
   ├── entry.client.tsx (To start MSW in dev mode)
   └── utils/
       └── api.ts       (Our API helper functions)
├── src/
   └── mocks/
       ├── handlers.ts  (MSW request handlers)
       └── browser.ts   (MSW setup for browser)
└── package.json
Enter fullscreen mode Exit fullscreen mode

🎁 Installing MSW

MSW isn't just a testing tool; it's a game-changer for developer experience (DX). By intercepting network requests at the service worker level, MSW allows you to:

  • Develop without a Backend: Mock complex API scenarios (like network delays, specific success payloads, or various error states) right in your browser, even if your backend isn't ready or accessible.

  • Isolate Frontend Logic: Focus purely on your UI and data flow, knowing exactly how your API will respond, without worrying about flaky network connections or third-party service downtimes.

  • Build Robust UIs: Precisely simulate edge cases like API failures, allowing you to thoroughly test the resilience and error-handling of your optimistic updates.

pnpm add msw --save-dev
Enter fullscreen mode Exit fullscreen mode

Let’s start by installing the preferred library where we will define handlers and intercept the HTTP requests for getting our fruits from our fruits API.

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';


export const handlers = [
 http.get('/api/fruits', () => {
   // Adding MSW interceptor and simulate a slight network delay
   return HttpResponse.json(
     [
       { id: '1', name: 'Apple', color: 'red' },
       { id: '2', name: 'Banana', color: 'yellow' },
       { id: '3', name: 'Orange', color: 'orange' },
     ],
     { status: 200, statusText: 'OK' },
     { delay: 500 } // 500ms
   );
 }),


 http.post('/api/fruits', async ({ request }) => {
   console.log('MSW: Intercepted POST /api/fruits');
   const data = await request.json();
   const newFruitName = data.name;


   // Simulate a random server error
   if (Math.random() < 0.2) {
     console.log('MSW: Simulating a server error for new fruit');
     return HttpResponse.json(
       { error: 'MSW simulated server error: Failed to add fruit!' },
       { status: 500 },
       { delay: 800 }  // 800ms
     );
   }


   // Simulate successful creation
   const newFruit = { id: `msw-${Date.now()}`, name: newFruitName, color: 'green' };
   return HttpResponse.json(
     newFruit,
     { status: 201 },
     { delay: 800 } // 800ms
   );
 }),
];
Enter fullscreen mode Exit fullscreen mode

Let’s import the export function into our main file that will intercept requests in the browser.

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';


export const worker = setupWorker(...handlers);
Enter fullscreen mode Exit fullscreen mode

⚡️ Integrate MSW into Remix Client Entry

We need to tell Remix to start the MSW worker when our client-side application boots up, but only in development.

// app/entry.client.tsx
import { RemixBrowser } from '@remix-run/react';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';


async function hydrate() {
 // Only start MSW in development environment
 if (process.env.NODE_ENV === 'development') {
   const { worker } = await import('~/mocks/browser');
   await worker.start({ onUnhandledRequest: 'bypass' }); // Start the worker and bypass unhandled requests
   console.log('🌈 MSW worker started for awesome fruit mocking!');
 }


 startTransition(() => {
   hydrateRoot(
     document,
     <StrictMode>
       <RemixBrowser />
     </StrictMode>
   );
 });
}


if (window.requestIdleCallback) {
 window.requestIdleCallback(hydrate);
} else {
 setTimeout(hydrate, 1);
}

Enter fullscreen mode Exit fullscreen mode

✅ Creating API helper function

Instead of directly calling a database, our Remix loader and action will now make standard fetch requests to /api/fruits.

MSW will intercept these during development 💣 !

// app/utils/api.ts

export type Fruit = {
   id: string;
   name: string;
   color: string;
};


export async function fetchFruits(): Promise<Fruit[]> {
   const response = await fetch('/api/fruits'); //  MSW Interceptors
   if (!response.ok) {
       const errorData = await response.json().catch(() => ({})); // Try to parse error
       throw new Error(errorData.error || 'Failed to fetch fruits');
   }
   return response.json();
}


export async function createFruit(name: string): Promise<Fruit> {
   const response = await fetch('/api/fruits', { // MSW Interceptors
       method: 'POST',
       headers: {
           'Content-Type': 'application/json',
       },
       body: JSON.stringify({ name }),
   });


   const data = await response.json();
   if (!response.ok) {
       throw new Error(data.error || 'Failed to create fruit');
   }
   return data;
}

Enter fullscreen mode Exit fullscreen mode

🎯 Adding Optimistic UI into our Remix Route

Now for the core of our application: _index.tsx. This file will handle loading the fruits, displaying them, and the form for adding new ones with optimistic updates.

We will add necessary import we need for our main index file. Which looks like this

// app/routes/_index.tsx


import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useFetcher } from "@remix-run/react";
import { useState, useEffect } from "react";
import { fetchFruits, createFruit, Fruit } from "~/utils/api";
Enter fullscreen mode Exit fullscreen mode

After this we can add our server function which will run on the server and re-runs after mutation is done. Loader functions in Remix that load data for your routes before rendering the UI. They're mostly used to fetch data that your component needs on initial load inside an SSR app.

// app/routes/_index.tsx
export async function loader({ request }: LoaderFunctionArgs) {
 try {
   const fruits = await fetchFruits();
   return json({ fruits });
 } catch (error: any) {
   console.error("Loader error:", error);
   return json({ fruits: [], error: error.message || "Failed to load fruits" }, { status: 500 });
 }
}

Enter fullscreen mode Exit fullscreen mode

Also, action functions in Remix that handle form submissions or other POST/PUT/DELETE requests. They manage server-side logic and then return data or redirection instructions.

// app/routes/_index.tsx
export async function action({ request }: ActionFunctionArgs) {
 const formData = await request.formData();
 const name = formData.get("name");


 if (typeof name !== "string" || name.trim() === "") {
   return json({ error: "Fruit name is required!" }, { status: 400 });
 }


 try {
   const newFruit = await createFruit(name.trim());
   return json({ newFruit });
 } catch (error: any) {
   return json({ error: error.message || "Failed to add fruit." }, { status: 500 });
 }
}

// Component
export default function Index() {
 const { fruits: serverFruits, error: loaderError } = useLoaderData<typeof loader>();
 const fetcher = useFetcher<typeof action>();


 // State to hold our optimistically added fruit
 const [optimisticFruit, setOptimisticFruit] = useState<Fruit | null>(null);


 // Combine server fruits with the optimistic fruit for display
 const displayFruits = [
   // If the fetcher is submitting AND we have an optimistic fruit, prepend it
   ...(fetcher.state === 'submitting' && optimisticFruit
     ? [{ ...optimisticFruit, id: `optimistic-${optimisticFruit.id}` }]
     : []),
   ...serverFruits, // The actual fruits from the server
 ];


 // Handler for form submission
 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
   const form = event.currentTarget;
   const formData = new FormData(form);
   const name = formData.get("name");


   if (typeof name === "string" && name.trim() !== "") {
     // 1. Optimistic Update
     setOptimisticFruit({
       id: Date.now().toString(),
       name: name.trim(),
       color: 'gray',
     });


     // 2. Submit the form to the Remix action using fetcher
     // The `action` prop here points to the route's action, or a specific API path
     fetcher.submit(formData, { method: "post", action: "/api/fruits" });


     // Clear the input field for a smooth user experience
     form.reset();
   }
   event.preventDefault(); // Prevent default browser form submission
 };


 useEffect(() => {
   if (fetcher.data) {
     if (fetcher.data.error) {
       // If the server reported an error, revert the optimistic change
       setOptimisticFruit(null);
       alert(`Failed to add fruit: ${fetcher.data.error}`); 
// Simple error feedback
     } else if (fetcher.data.newFruit) {
       // If successful, clear the optimistic state.
       // Remix's revalidation will cause the loader to run again,
       // fetching the *actual* updated list of fruits from MSW
       // which will now include the newly added fruit.
       setOptimisticFruit(null);
     }
   }
 }, [fetcher.data]);


 return (
   <div>
     <h1>🍓🥕 My Optimistic Fruit List 🍏🍋</h1>


     <fetcher.Form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
       <input
         type="text"
         name="name"
         placeholder="Enter new fruit name"
         required
         disabled={fetcher.state === 'submitting'} // Disable input while submitting
       />
       <button
         type="submit"
         disabled={fetcher.state === 'submitting'} // Disable button while submitting
         style={{ padding: '8px 15px', borderRadius: '4px', border: 'none', background: '#007bff', color: 'white', cursor: 'pointer' }}
       >
         {fetcher.state === 'submitting' ? 'Adding Fruit...' : 'Add Fruit'}
       </button>
     </fetcher.Form>


     {/* Display errors from action or loader */}
     {(fetcher.data && fetcher.data.error) && (
       <p style={{ color: "red" }}>Error: {fetcher.data.error}</p>
     )}
     {loaderError && <p style={{ color: "red", marginTop: '10px' }}>Loader Error: {loaderError}</p>}




     <h2>🍽️ Available Fruits</h2>
     <ul style={{ listStyle: 'none', padding: 0 }}>
       {displayFruits.map((fruit) => (
         <li
           key={fruit.id} // Should be Unique
           style={{
             opacity: fruit.id.startsWith('optimistic-') ? 0.6 : 1,
             fontStyle: fruit.id.startsWith('optimistic-') ? 'italic' : 'normal',
             color: fruit.id.startsWith('optimistic-') ? '#888' : '#333'
           }}
         >
           {fruit.name}
           {fruit.id.startsWith('optimistic-') && fetcher.state === 'submitting' && (
             <span> (Pending...)</span>
           )}
         </li>
       ))}
       {displayFruits.length === 0 && !loaderError && !fetcher.state === 'submitting' && (
         <p>No fruits yet. Add some!</p>
       )}
     </ul>
   </div>
 );
}

Enter fullscreen mode Exit fullscreen mode

✏️ Conclusion

Optimistic UI, empowered by Remix's robust data handling and MSW's incredible mocking capabilities, allows us to build web applications that not only function correctly but also feel incredibly fast and responsive. Remix’s intelligent handling of data with loader and action functions, coupled with the granular control offered by useFetcher, makes optimistic updates remarkably straightforward to orchestrate. It elegantly manages the dance between immediate UI feedback and eventual server-side reconciliation.

This combination is an absolute game-changer for perceived performance and user satisfaction. Give it a try in your next Remix project, and let your users enjoy the instant gratification of an optimistically updated interface.

Top comments (0)