@composi/runtime:
Subscriptions
Running Effects at Startup
When we covered init
it was explained how you could execute an effect at startup by passing it as a second value in the tuple that init
returns. Generally effects that run at startup are referred to as subscriptions. You can be more explicit about implementing a subscription by using the subscriptions
method on a program. This needs to return the subscription to execute.
You would use subscriptions to track mouse movements, keyboard events, scrolling, changes to browser location, or timers.
function startCount(send, getState) {
let count = 0
const id = setInterval(() => {
if (count === 10) clearInterval(id)
count += 1
program.send(count)
}, 1000)
}
const program = {
init() {
return state
},
view(state, send) {
return render(The count is: {state}.
, document.body)
},
update(state, msg, send) {
if (msg) {
// Set program state to value of message:
return msg
}
},
subscriptions(send, getState) {
return startCount(send, getState)
}
}
There is no magic here. Subscriptions do what the label says. Using subscriptions to run effects at start is more explicit. But you can use whatever approach, init or subscriptions, to run effects at startup. Subscriptions receive send
and getState
as arguments. This means you can access the program's state and send a message to an update action. getState
is necessary because since subscriptions run separately from the program, they don't have direct access to the program's state. Using getState
you can get the current state of the program and using send
you can send a message to an action.
Both send
and getState are optional. If you are not accessing state in a subscription, no need for the getState
argument. If a subscription is not going to send a message, no need for send
as an argument:
const program = {
init() {
},
view(state, send) {
},
update(state, msg, send) {
},
// send and getState are optional here:
subscriptions(send, getState) {
}
}
Note: Subscriptions are not for manipulating state. Changing state in an subscription will result in the view and state being out of sync. If you have produced some data in an effect and want to update state, do so by sending a message to an action. Doing so will trigger the reconciliation algorythm and a possible re-render of the view.
If you need to use some value from state in a subscription be careful to always make a clone of state before using it. You can use the clone function form @composi/merge-objects, or Object.assign or destructuring.
Batched Subscriptions
Like any other effects, you can use batch
to run multiple effects at the same time. See the documentation for effects to learn how to use batch
.
Now let's look at how to use batched subscriptions. We are going to take two effects, one to fetch data and one to set up an event to listen for key presses that we want to execute when our program starts. Since there are two effects, rather than putting them directly in the body of the subscriptions
program method, we'll keep them separate and use a batched version. Since we're fetching data at start up, there will be no state for the initial vew render. In our view we'll check whether state is truthy or not before returning the rendered view. And in our initial setup for state in the init
method we will set state to null.
// Create a tagged union for message:
const Msg = union('useFetchedData')
// Below are two effects.
// Each effect expects the `send` function as its argument.
// This will get passed automatically when the batched effects are processed.
// Subscription effect to fetch data:
function fetchState(send, getState) {
(async () => {
const response = await fetch('/src/js/state.json')
const data = await response.json()
send(Msg.useFetchedData(data))
})()
}
// Subscription effect to handle Enter key:
function handleEnter(send, getState) {
document.addEventListener('keypress', e => {
if (e.keyCode === 13) {
send(Msg.addItem())
}
})
}
// Batch subscrption effects together:
const batchedSubscriptions = batch(fetchState, handleEnter)
// Define program to run:
const program = {
init() {
// Here we set state to null:
return null
},
view(state, send) {
// Here we check if state is truthy or not:
return state && render(
, 'section')
},
update(state, msg, send) {
const prevState = clone(state)
return actions(prevState, msg)
},
subscriptions(send, getState) {
// Here we return the batched effects.
// Notice that we do not need to call them, just return them.
return batchedSubscriptions(send, getState)
}
}
Shortcut
Instead of using subscriptions
you can use the shorter form subs
:
const program = {
init() {
return state
},
view(state, send) {
return render(The count is: {state}.
, document.body)
},
update(state, msg, send) {
if (msg) {
// Set program state to value of message:
return msg
}
},
// Use short form for subscriptions:
subs(send, getState) {
return startCount(send, getState)
}
}
Accessing State
By their nature subscriptions are async operations. They launch when the program first runs. They get state and the send
function passed to them as arguments. Depending on what a subscription is doing, you can send a message to an action to handle so that the state can be updated.
Subscriptions run independently and isolated from the rest of the program. You cannot send a message to a subscription. And, because of the way state is handled, subscriptions only receive state as it was at the time the program started. If state changes after that, subsciptions are unaware. They have only a copy of state as it was at startup.
To get around this isolation to enable better means of handling subscriptions there is a special, static method exposed on the program: getState
. This exists to enable a subscription to access the current state of a program. Using this, a subscription can run a loop to check a state value to use for making a decision, such as to cancel a subscription or perform some other task.
program.getState()
To access the current state of a project, you use the getState
which gets passed as the first argument of a subscription. Say you have a program like this:
// src/js/app.js:
import {h, render, run, union} from '@composi/core'
import {Main} from './components/main.js'
import {actions} from './effects/actions'
import {subscription} from './effects/subscription'
const program = {
init() {
return state
},
view(state, send) {
return render(<Main {...{state, send}}/>, 'body')
},
update(state, msg, send) {
return actions(state, msg, send)
},
subscriptions(send, getState) {
return subscription(send, getState)
}
}
Now say your subscription was doing this:
// src/js/effects/subscription.js:
import {clone} from '@composi/merge-objects'
function subscription(send, getState) {
// clone the current program state:
const state = clone(getState())
const id = setInterval(() => {
if (state.items.length < 10) {
console.log(`The number of items is: ${state.items.length}`)
console.log('Not enough items yet!')
}
}, 1000)
}
If the user where to add new items, this subscription doesn't have to worry because getState
will always return the current program state. That way it can know when the user adds the required number of items.
Warning!
Never ever use getState
to directly manipulate state. Instead get state, clone it, then do what you need to do with the clone. Directly changing state inside a subscription will result in the view and state being out of sync. Remember, only when an action returns state does the view get updated. That's why that function is called update
. Returning state from a subscription will do nothing. You can send data from a subscription to an action via a message, which will result in the view being updated.
How to Use Subscriptions
You could use a subscription to get data from somewhere or create data and send that data as a message for an action to handle. Or you might use a subscription to setup a websocket or run a loop for logging. You could also use a subscription to get the current state of a program to examine it and make a decision. This could result in a subscription canceling itself or sending a message to an action to update state.
Actions are for updating state. Subscriptions are for running effects independently of the program that launch at startup. When something in a subscription warrants that the state be updated, the subscription must send a message to an action to handle the task. Remember that if a subscription creates data that you want to expose as state, you can send that data as the value of a message for an action to handle. The action can expose the message data on the state, which will automatically update the view.