DEV Community

Cover image for Making Async, Animated Toasts with an RxFx Service
Dean Radcliffe
Dean Radcliffe

Posted on • Edited on

Making Async, Animated Toasts with an RxFx Service

A use case: A list of notifications

One time, I needed to code up a list of Notifications, commonly known as "toasts". These pop up for a user's attention, populated by an API or some event, and can be dismissed by the user. They may look like this:

notification list with fade

I wanted to build this in pure JS, but use it in React. At first I had an OOP model of it in mind- like this:

class Notifications<T> {
  private items: T[] = []
  get items(): T[] { }

  push(item: T){ }
  remove(item: T){ }
}
Enter fullscreen mode Exit fullscreen mode

I wondered - could I use an object like this, in React, in such a way that whenever an item is added, the UI always displays the current items? It seemed this object model wasn't enough to support a fully reactive list. I didn't want to build this out of React state - I wanted my items in pure JS, so any file that wanted a real-time view of them could simply import and use them. I did not want to worry about Context, or ReactTransitionGroup, or any other view-layer-specific issues, just code in pure JS!

I also wondered how to implement them with fade-in/fade-out, since it's definitely smoother and less confusing with a transition.

I first tried to write an API like this, wrapping a hook around our object:

const notifications = new Notifications<string>;
notifications.push('Svelte')

function Notifications() {
  const items = useObject(notifications, 'items')
  const addNew = () => notifications.push('RxFx')

  return <ol onClick={addNew}> 
    { items.map(item => (<li>{ item }</li>)) }
  </ol>
}
Enter fullscreen mode Exit fullscreen mode

I tried several times to implement this useObject hook, including polling items, but I was never happy with it. Particularly since it didn't address animation, and looked bad without it.

notification list with no fade

notification list with fade

Then I found an object called a Service, from the @rxfx/service package. With a Service, I could have what I wanted, even queued animations! I only had to change the API from the useObject hook by a little bit:

const notifications = new createAsyncListService<string>();
notifications.request({ method: 'push', item: 'Svelte'})

function Notifications() {
  const { state: { items }} = useService(notifications)
  const addNew = () => {
    notifications.request({ method: 'push', item: 'RxFx'})
  }

  return <ol onClick={addNew}> 
    { items.map(item => (<li>{ item }</li>)) }
  </ol>
}
Enter fullscreen mode Exit fullscreen mode

Instead of directly calling methods, I send objects with the method name into the service via its request() method. I invoke useService with a service you'll see defined shortly. Then I extract the state from the return value of useService, and pluck of its items field.

To ensure it was reactive, I wrote the addNew click handler to add an object on the fly. And did it update reactively? It did! Now, I had the reactive, async, animating list of my dreams!

And so I thought I'd share how you can build this functionality in just a few lines of code with an RxFx service, ending up with framework free code you can use in any view layer from ReactNative to Angular to Svelte.

Defining the Service

The first thing to understand about an async list vs. an immediate synchronous list, is that items can be in a state of entering or leaving, as well as fully present. So the state model can not simply be a list of items as in the OOP model.

There are at least two ways a service could represent this state.

Option 1 is that the state could contain a value ranging from 0 to 100, updated on every animation frame, representing how fully in the list the item was. So, for an appearing item in the list:

items: [
  {
    value: "RxFx"
    percentComplete: 95
  },
]
Enter fullscreen mode Exit fullscreen mode

percentComplete could be used to update DOM properties like opacity, or height. But this approach fails to make use of CSS' power to perform animations with just a class name. And it's also not clear whether the item is coming or going.

Option 2 might be to model our state with items, but also with entering and leaving. Imagine we already had 'Svelte' in the list, and pushed 'RxFx'. The state would resemble this, until 'RxFx' is done animating in.

{
  items: ['Svelte', 'RxFx'],
  entering: ['RxFx'],
  leaving: []
}
Enter fullscreen mode Exit fullscreen mode

This is the state model RxFx' createAsyncListService supplies. Here's all we have to do to use it. We define the service like this:

import { createAsyncListService } from "@rxfx/service";

const PER_ITEM_DELAY = 250;
const notifications = createAsyncListService<string>(
  "list/notifications",
  PER_ITEM_DELAY
);
Enter fullscreen mode Exit fullscreen mode

The (optional) arguments are a name for logging, which we'll see more of later. And a delay in milliseconds for each animation.

Notice this service definition can and should live outside of any view component, it's just pure JavaScript! Let's see how to make our view layer react to it now.

Using the Service

Thanks to a utility from the @rxfx/react library called useService, the state of the service can be easily brought into the React view. This gives us arrays items, and entering, and we only need to check if an item is in entering, to assign a CSS class to it to fade it in. The component using the service looks like this:

function List() {
  const {
    state: { items, entering },
  } = useService(notifications);

  const addNew = () => {
    notifications.request({ method: 'push', item: 'RxFx'})
  }

  return <ol onClick={addNew}> 
    { items.map((item) => {
        const isEntering = entering.includes(item);
        const className = isEntering ? "item-entering" : "";

        return <li className={className}>{item}</li>;
      });
    }
  </ol>
}
Enter fullscreen mode Exit fullscreen mode

You are easily able to test that calling addNew adds an item to the service state, and React picks up on it right away. If you wanted to see every change of the service state, you could also send it to the console on every change with this:

notifications.state.subscribe(console.log)
Enter fullscreen mode Exit fullscreen mode

And if you wanted to see every lifecycle event of the service - you can use the fact that it's a listener on an RxFx event bus, and simply log all event bus events to the console:

import { defaultBus } from '@rxfx/service'

defaultBus.spy(({ type, payload }) => console.log(type, payload))
Enter fullscreen mode Exit fullscreen mode

This makes debugging a breeze. Now, to finish things up, we just need some CSS to enable the fade, and voila, we're animating!

@keyframes fadeIn {
  0% { opacity: 0;  }
  100% { opacity: 1;  }
}

.item-entering {
  animation: fadeIn var(--per-item-delay);
}
Enter fullscreen mode Exit fullscreen mode

A finishing touch is to send the same DELAY we used in JavaScript into CSS, so the CSS animation and the service' delay are the same:

document
  .querySelector("#root")
  .style.setProperty("--per-item-delay", (PER_ITEM_DELAY / 1000) + 's');
Enter fullscreen mode Exit fullscreen mode

(Thanks to David Khourshid of Stately AI for introducing me to CSS variables)

Now, see the CodeSandbox for the working version.

Use @rxfx/service for all the things!

It turns out, this @rxfx/service container is useful for any async effect, from animation to AJAX. And it has a standard API: .request(), .state.value, and .cancelCurrent(), so you learn it once, and use it for many use cases. Even better, this code is directly usable in Svelte, or Angular or Vue, since it is pure JS/TS code with no view-layer dependencies!

It's built on RxJS, and in other articles I'll show you how other async situations can be handled easily by wrapping an effect in an @rxfx/service.

Happy Notifying!

Please star @rxfx/service on GitHub!

Appendix: Integrating with Redux

Many times, the need for animation comes up after a basic non-animated solution is in place. This was the case for these notifications. The items were in a Redux store, a dispatch was used to put an item into the store, and a useSelector hook brought the items to the page. After we add the RxFx service to handle transitions - will we need to keep Redux?

In this app, in this particular case, we want to keep Redux informed of our list of notifications. We'll have the benefit of the Redux ecosystem, like Redux-Persist, to save and load the store to localStorage.

So, we'll use the service for its animation ability, and Redux for persistence of state.

Inside a custom hook, make the service an intermediary

Initially the Notifications component looked like this:

function Notifications() {
  const { items, addNew, dismiss } = useNotifications();
  /* render items */
}
Enter fullscreen mode Exit fullscreen mode

A custom hook useNotifications defined the API to notifications - you can retrieve items, or addNew or dismiss, and these were implemented by a selector, and sending dispatches.

We can keep this API - but internally we want to make the service an intermediary.

  • Instead of dispatching, we'll make request() calls to the service
  • The items of our service are used to populate Redux
  • items can still come from Redux
  • We can get a class name for an item, based on the state of the service.

We'll add a getClassName(item) to the hook's return value, then get to work inside:

Changing the custom hook

Inside the hook, push and remove performed dispatches, and items populated by a selector.

Instead, now we'll change the dispatching functions to make request() of our service. This ensures our service is in charge of all changes to items.

Next, we need to sync these items to Redux as they change. We'll use the power of RxJS to ensure that we have an Observable of only changes of items. This enables us to keep Redux free of any information like what is entering and leaving, and focus only on what should persist across sessions.

The finished useNotifications hook looks like this:

import { map, distinctUntilChanged } from 'rxjs'
import { store } from './store'
import { notifications } from './services/notifications'

// Sync to Redux
notifications.state.pipe(
  map(state => state.items),
  distinctUntilChanged()
).subscribe(items => store.dispatch(setItems(items)))

export const useNotifications() {
  const { state: { items, entering, leaving } } = useService(notifications);

  function getClassName(item) {
    /*  based on whether entering/leaving */
  }

  // Make changes first to our service
  function addNew(item) {
    notifications.request({ method: "push", item })
  }
  function dismiss(item) {
    notifications.request({ method: "remove", item })
  }

  return { items, addNew, dismiss, getClassName }
}
Enter fullscreen mode Exit fullscreen mode

With that, we now have the service as our source of truth for the state of the UI, and Redux is a persistence layer, with all the benefits of its DevTools and persistence layer.

notification list with Redux

So, that's how we integrate. We let Redux do persistence, and let dynamic effects and animations live in a service, which is not persisted. A service will help you keep state fields like loading out of your stores. That's right - you don't need to track loading if you have a service! If you want to know whether the animation is running, you can pluck off the isActive field from the useService return value.

Top comments (0)