Désactiver le thème sombre

Article | Back to articles

UX Files - Coding the logic for notification toasts

23/12/2020 by Benoit Rajalu

Abstract representation of a notification Toast being coded

This post is part of UX Files, a series of articles where I investigate interface patterns through three lenses: UX, UI and development. Let's start with a look at the Toast.

We're now two steps deep down our path to make a sensible Toast. We've looked at reasons why we might need them and we've got a design telling us how they should look and behave. We have our plans, we must now make them happen.

Remember we can't be building this for a specific app. We don't have one to get our context from, and I don't think we should. Approaching the issue as we would an agnostic package has its perks: we must design our API to be easy to use, we must cater to any and all cases consumers might throw at us and we have to document for an unpredictable public. Being as empathic as we can when developing will help us achieve a better architecture.

An architecture for what exactly? We need two things: a way to handle the logistics of triggering a Toast and some UI to actually display it. Logic and interface done according to two articles' worth of specifications. Easy peasy.

The logic will be written with Typescript and the views with React, but both will be separate enough. If you like it, you can use the TS code in a non-React app.

Making the state

I was recently introduced by a former colleague to the notion that the state of our apps should be where the real business questions are answered. The responsibilities and possibilities of what you're building should exist in one single parsable place and the state, representing the model of our app, should be that place. He calls that the "domain", but that's another story.

We are in the business of Toasts then. First, we'll define what they are.

Designing our Toasts as types

We know Toasts can have links or actions. They must have content. They can be either positive, negative or neutral. Negative toasts cannot be self-dismissing. Toasts must be sorted by priority.

We can build a model of our Toasts that translate these business rules as code using strong types.

typescript


  type Priority = "HIGH" | "LOW" | "MEDIUM";
  
  type BaseToast = {
    copy: string;
    id: string;
    priority: Priority;
    createdAt: Date;
    action?: {
      copy: string;
      callback: () => void;
    };
    link?: {
      copy: string;
      href: string;
    };
  };
  
  export type ToastDefault = BaseToast & {
    variant: "default";
    dismissDelay?: number;
  };
  
  export type ToastPositive = BaseToast & {
    variant: "positive";
    dismissDelay?: number;
  };
  
  export type ToastNegative = BaseToast & {
    variant: "negative";
  };
  
  export type Toast = ToastDefault | ToastPositive | ToastNegative;
  
  export type Stack = Toast[];


Defining a private BaseToast and publicly exported types separates the variant and their specific ruleset. We can use them to craft an all encompassing Toast type from which we make our Stack. It's nothing but an array of Toasts after all.

Designing the way we use the Toasts

We have our types but we still haven't got our API. Now is where the "design with empathy" part must shine. It doesn't necessarily mean we must code in a specific way, but it does mean we must deliver the best toolset for consumers to use.

Based on the target guidelines and the above types, we know we have to:

  • Create Toasts of specific variants
  • Distinguish a variant from another
  • "Push" a Toast to the stack
  • Remove a Toast from the stack
  • Order the stack using priority rules

This is all relative to our model. The way we achieve that in our view is irrelevant. We simply must provide ways for the API's users to perform these tasks easily.

The Toasts

To ease the creation of Toasts we can provide "creator" functions based on our specific types. Using these functions will help the API consumer as it removes their need to fret on the details of what Toasts are. Provide the attributes requested by the function and all will be well.

typescript


export function makeDefault(
 props: Omit<ToastDefault, "variant">
): ToastDefault {
 return {
   ...props,
   variant: "default"
 };
}
 
...
// Same for makePositive, but with 
// ToastPositive as a type and &quot;positive&quot; as a variant
 
export function makeNegative(
 props: Omit<ToastNegative, "variant">
): ToastNegative {
 return {
   ...props,
   variant: "negative"
 };
}

Now we need to ensure our consumers can easily distinguish our Toasts by variant when they are parsing our Stack. To do this, we will create type checking functions that will read the value of the "variant" attribute. With them under our belt we can create a fold, a function that, given a callback for each type of variant, will return the correct callback for a given toast. Let me clarify:

typescript


// Testers
export function isPositive(toast: Toast): toast is ToastPositive {
  return toast.variant === "positive";
}

export function isNegative(toast: Toast): toast is ToastNegative {
  return toast.variant === "negative";
}

export function isDefault(toast: Toast): toast is ToastDefault {
 return !isNegative(toast) && !isPositive(toast);
}

We can now individually test that isDefault(toast) returns false if the given toast is of either "negative" or "positive" variants. But using each of these individually to know which is which would be unwieldy. Enters the fold:

typescript


// Handy fold to get the right type of Toast easily
export function fold<R>({
  onPositive,
  onNegative,
  onDefault
}: {
  onPositive: (toast: ToastPositive) => R;
  onNegative: (toast: ToastNegative) => R;
  onDefault: (toast: ToastDefault) => R;
}) {
  return (toast: Toast) => {
    if (isPositive(toast)) {
      return onPositive(toast);
    }

    if (isNegative(toast)) {
      return onNegative(toast);
    }

    return onDefault(toast);
  };
}

This saves us a lot of time. Given three callbacks, each based on refined Toast types, the fold will evaluate a toast and return the proper callback. You may have noticed the currying of the function. It enables us to use this fold like fold({onPositive: ()...})(toast), a signature that is highly appreciable in a functional programming setting where pipe (a function meant to mimic the fabled pipe operator) or flow are everywhere.

ES6 purists may also wonder why I've chosen to write it the old-fashioned way, not going all out with arrow functions. Arrow functions are anonymous, it makes them hard to debug in developer tools. Using named functions here is a small price to pay for a better developer experience down the line.

We're done with outfitting our API consumers with basic tools to build and separate Toasts, but we still need to address pushing to and removing from the Stack.

The Stack

Our API gives us what we need to work with the Toasts but they won't be going too far without a Stack. That's also where most of our business logic lives: the Stack handles the coming and going of its Toasts while enforcing the rules we set for ourselves.

Let's remind ourselves what they are:

  • Negative Toasts are more important than Positive ones, and they are both more important than Default Toasts.
  • Toasts have priority levels. High, medium or low. Two toasts of the same variant types should be ordered according to this priority.
  • When the first two conditions are spent, the oldest Toast has priority over the other.
  • We can't have two Toasts with the same id.

These rules ensure we display the most important Toast and keep the rest in our Stack. Everytime we update a Toast or add one, we must therefore ensure the rules are followed. Our Stack's type is Toast[] so these rules are simply a way to say we must sort our array in a specific order.

Let's materialize these rules!

We're going to use fp-ts to handle the heavy load here. As the name suggests, it's a functional programming library for Typescript.

It can be brutally obscure at times because its documentation relies less on examples as it does on your ability to decode signatures. Such a steep learning curve can be discouraging but the library is full of powerful tools to build strong, reliable APIs.

Here we'll use the Eq toolset to handle equality and the Ord toolset to handle ordering. Using them we can create the tools representing each of our ruleset bullet list points.

typescript


// This take values from our objects then compare them
// Here we'll compare the Toasts' ids
const toastIdEq = Eq.contramap((toast: Toast) => toast.id)(Eq.eqString);

// We create records to store equivalencies between our variants / priorities and plain numbers
const variantRecord: Record<Variant, number> = {
  negative: -1000, // Highest priority
  positive: 0,
  default: 1000 // Lowest
};

const priorityRecord: Record<Priority, number> = {
  HIGH: -1000,
  MEDIUM: 0,
  LOW: 1000
};

// This Ord works pretty much in the same way as the Eq
// It takes values from the object then map them to the Record
// Then ordNumber helps ordering them
const ordVariant = Ord.contramap(
  (toast: Toast) => variantRecord[toast.variant]
)(Ord.ordNumber);

// Same as above for Priority
const ordPriority = Ord.contramap(
  (toast: Toast) => priorityRecord[toast.priority]
)(Ord.ordNumber);

const ordAge: Ord.Ord<Toast> = Ord.contramap((toast: Toast) => toast.createdAt)(
  Ord.ordDate
);

Some of the jargon here can be unfamiliar. I did warn you about fp-ts' unfriendliness after all. They rely on functional programming patterns I won't pretend I understand completely. What I can offer is a beginner's guidance, an entry level decoder for this foreign language.

Here a contramap is a way to "extract" part of a given object and feed it to an Ord or Eq entity. We use them to extract the relevant parts of our Toasts.

An Ord entity such as ordPriority represents types that can be ordered comparatively. Simply put, Ord entities relying on ordNumber will be ordered comparing numbers in ascending order.

An Eq entity works in a comparable way but does no ordering. It only checks equality based on a set criteria. Here we know our Toast's ids are strings, so we check string equality.

None of this toolset is applied yet. We need to assemble all of these in our Stack API.

Here's how it looks inside our function built to add a Toast or update one in our Stack:

typescript


export function updateStack(toast: Toast) {
  return (stack: Stack): Stack => {
    if (ArrayFP.isEmpty(stack)) {
      return [toast];
    }

    const newStack = pipe(
      stack,
      ArrayFP.cons(toast), // Add the toast to the begining
      ArrayFP.uniq(toastIdEq), // Remove duplicates
      ArrayFP.sortBy([ordVariant, ordPriority, ordAge]) // Sort by variant, then priority, then age
    );

    return newStack;
  };
}

(ArrayFP is, you've guessed it, also courtesy of fp-ts)

Given this toolset we know we can trust our pushing and updating into the Stack will not break our rules. We lack only the way to remove a Toast from the stack, which is much more straightforward.

typescript


export function removeFromStack(toast: Toast) {
  return (stack: Stack): Stack => {
    const stackWithoutToast = pipe(
      stack,
      ArrayFP.filter((toastInStack: Toast) => toastInStack.id !== toast.id)
    );

    return stackWithoutToast;
  };
}

With that we have our API. We have a representation of our model and the means to manipulate it. Our core business logic is translated as functional code.

Here's a link to the complete file in Codesandbox if you need to dive deep in that code.

We can' build the views just yet. We have our "state", but it's only a model. We need to give it a "store", a place to exist in the memory.

Making the store

In the introduction I promised we'd deliver a logic that could be used outside of a React context. To that end, I'm going to use @iadvize-oss/store-library, an open-source package delivering a functional-programming friendly, context-agnostic store library.

With its help we will capitalize on our functional API to update our state elegantly.

It's also very simple to use.

typescript


import { createStateHook } from "@iadvize-oss/store-react";
import * as Store from "@iadvize-oss/store";
import * as Toast from "./API";

// First we define the shqpe of our state (what does it take, how does it start)
function create(defaultState: Toast.Stack = []) {
  return Store.create<Toast.Stack>(() => defaultState)();
}

// Then we initialize it
export const store = create();

// This enables us to get the Store's contents from a React hook
export const useToastState = createStateHook(() => store);

// This enables us to "lift" changes to the store using our API
// here apply is an IO, a function. We'll need to to call it
// ie: applyStackModifier(updateStack(toast))()
export const applyStackModifier = store.apply;


The library comes with a separate package for React that will be useful down the line in our views, but you can just as easily stop there and connect any other front-end to this API and store.

We can create Toasts, update them, push and remove from a Stack while respecting our ruleset, and now we can store all of that in a tidy store. Now let's work on making these Toasts appear on screen!

Let's build our views!