Composing Custom Effects

Being composable

Use the core effects is meant to be easy. That's because they are composable, which means you could nest the effects inside each other. For example, if you want to run an action through a thunk and promise, you could easily write:

effects.thunk(() => (
  effects.defer((resolve) => {
    resolve(effects.act((state) => [state, effects.none()]);
  })
))

Of course, if you do this frequently, it's probably a good time to wrap up the common pattern in a custom effect.

Composing Complex Effects

Let's start off with a larger effect, one that I actually wrote for a demo project, and we'll go through the refactoring steps I had to making this two composable and powerful effects.

noncomposableNotificationEffect.js
import { effects } from 'ferp';

export const notificationEffect = (title, body) => effects.defer(
  Notification.requestPermission()
    .then((result) => {
      if (result === 'granted') {
        const notification = new Notification(title, { body });
      }
    })
    .catch(() => {})
    .then(() => {
      return effects.none();
    });
);

// usage: notificationEffect('Ferp.JS', 'Hello world!');

This is not awful, but it's also not great. There is no way to react to different permission settings, and the permission request is locked to showing individual notifications. My first refactor was to separate out the permission request from the actual notification:

notificationEffects.js
import { effects } from 'ferp'

export const notificationPermissionEffect = (
  grantEffect,
  denyEffect = effects.none,
  dismissEffect = effects.none,
  errorEffect = effects.none
) => effects.defer(
  Notification.requestPermission()
    .then((result) => {
      switch (result) {
        case 'granted':
          return grantEffect();
        case 'denied':
          return denyEffect();
        default:
          return dismissEffect();
      }
    })
    .catch(errorEffect)
);

export const notificationEffect = (title, body) => notificationPermissionEffect(
  () => effects.none(new Notification(title, { body })),
);

// usage: notificationEffect('Ferp.JS', 'Hello world');

That feels better and keeps the same signature as the first, but the notification effect will eat permission denied, dismissed and error, and it's limited in how I can react to the notification further. Let's explore an alternative composition:

composableNotificationEffects.js
import { effects } from 'ferp'

export const notificationPermissionEffect = (
  grantEffect,
  denyEffect = effects.none,
  dismissEffect = effects.none,
  errorEffect = effects.none
) => effects.defer(
  Notification.requestPermission()
    .then((result) => {
      switch (result) {
        case 'granted':
          return grantEffect();
        case 'denied':
          return denyEffect();
        default:
          return dismissEffect();
      }
    })
    .catch(errorEffect)
);

export const notificationEffect = (title, options = {}, resultEffect = effects.none) => effects.thunk(() =>
  resultEffect(new Notification(title, options))
);

// Usage:
// notifiationPermissionEffect(notificationEffect('Ferp.JS', { body: 'Hello world!' }))

This is a little more dense, but it offers the greatest flexibility. It would be easy to compose a message effect to store the notification in the state to react to it's events in a subscription. That could look like this:

notificationExample.js
import { app, effects, sub } from 'ferp';
import { notificationPermissionEffect, notificationEffect } from './composableNotificationEffects.js';

const INITIAL_STATE = {
  notifications: [],
};

const NotificationAdd = (notification) => (state) => [
  { ...state, notifications: state.notifications.concat(notification) },
  effects.none(),
];

const NotificationRemove = (notification) => (state) => [
  { ...state, notifications: state.notifications((n) => n !== notification) },
  effects.none(),
];

const NotificationCreate = (title) => (state) => [
  state,
  notificationEffect(title, undefined, (notification) => effects.act(NotificationAdd, notification)),
];

const delayEffect = (timeout, nextEffect) effects.thunk(() => effects.defer((resolve) => {
  setTimeout(resolve, timeout, nextEffect);
});

const notificationSubscription = (notification, onInteract) => (dispatch) => {
  const onClose = (event) => {
    dispatch(onInteract(notification));
  };
  
  const onClick = (event) => {
    dispatch(onInteract(notification));
  };
  
  notification.addEventListener('click', onClick);
  notification.addEventListener('close', onClose);
  
  return () => {
    notification.removeEventListener('click', onClick);
    notification.removeEventListener('close', onClose);
  };
};


app({
  init: [
    INITIAL_STATE,
    effects.batch([
      notifyEffect('First Notification'),
      delayEffect(1000, () => effects.batch([
        notifyEffect('Second Notification')),
        delayEffect(3000, () => notifyEffect('Third Notification')),
      ]),
    ]),
  ],
  subscribe: (state) => [
    ...state.notifications.map(notification => sub(
      notificationSubscription,
      notification,
      NotificationRemove,
    )),
  ],
});

Last updated