Composing Custom Effects

A basic message

The simplest effect is a plain old variable. This has often taken the form of an object, and commonly seen in redux as { type: 'unique name', ...args }. Ferp doesn't care what your message looks like, but messages should take on a consistent shape, whether that be objects with a type field, strings, or other data structures. Using a basic message can look like this:

stringMessageEffects.js
import { effects } from 'ferp';
const initialState = {
text: '',
};
const messages = {
test: 'test',
foo: 'foo',
};
const testEffect = { type: messages.test };
const fooEffect = { type: message.foo };
const stateReduce = (reduceKeyFn, defaultFn) => (message, state) => {
const next = updateByMessageType[message];
if (!next) return defaultFn(message, state);
return next(message, state);
};
const update = stateReduce({
[message.test]: (message, state) => [{ text: 'test' }, fooEffect],
[messages.foo]: (message, state) => [{ text: 'foo' }, testEffect],
}, (message, state) => [state, effects.none()]);

Notice the messages object and the two effect methods, testEffect and fooEffect. Just having messages is fine, but wrapping them in functions means you can use these without any changes when composing complex effects.

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 } from 'ferp';
import { notificationPermissionEffect, notificationEffect } from './composableNotificationEffects.js';
const messages = {
notificationAdd: 'notificationAdd',
notificationRemove: 'notificationRemove',
};
const createNotificationEffect = (type) => (notification) => ({
type,
notification
});
const notificationAddEffect = createNotificationEffect(messages.notificationAdd);
const notificationRemoveEffect = createNotificationEffect(message.notificationRemove);
const delayEffect = (timeout, nextEffect) effects.thunk(() => effects.defer(new Promise((resolve) => {
setTimeout(() => resolve(nextEffect()), timeout);
}));
const notifyEffect = (title) => notificationPermissionEffect(
notificationEffect(title, undefined, notificationAddEffect),
);
const notificationSubscription = (notification, clickEffect = effects.none) => (dispatch) => {
const onClose = (event) => {
dispatch(notificationRemoveEffect(notification));
};
const onClick = (event) => {
dispatch(effects.batch([
clickEffect(notification),
notificationRemoveEffect(notification),
]));
};
notification.addEventListener('click', onClick);
notification.addEventListener('close', onClose);
return () => {
notification.removeEventListener('click', onClick);
notification.removeEventListener('close', onClose);
};
};
const initialState = {
// ...
notifications: [],
};
app({
init: [
initialState,
effects.batch([
notifyEffect('First Notification'),
delayEffect(1000, () => effects.batch([
notifyEffect('Second Notification')),
delayEffect(3000, () => notifyEffect('Third Notification')),
]),
]),
],
update: (message, previousState) => {
switch (message.type) {
case messages.notificationAdd:
return [
{
...previousState,
notifications: previousState.notifications.concat(message.notification)
},
effects.none()
];
case messages.notificationRemove:
return [
{
...previousState,
notifications: previousState.notifications.filter(n => n !== message.notification)
},
effects.none()
];
}
},
subscribe: (state) => [
...state.notifications.map(notification => (
[notificationSubscription, notification]
)),
],
});