@composi/runtime:
Tagged Unions
Messages with Actions
When we were looking at how to use actions in the update function the usage was very similar to Redux. We
were sending messages, which are objects with a type and optional data. For sure Redux requires much more
boiler plate, and how you handle a message with a switch statement is similar to what Redux does. However,
@composi/core offers a solution to this boilerplate through tagged unions. These are implement by means of the
union
function. To use it you'll need to first import it into your project:
import { h, render, run, union } from '@composi/core'
A tagged union is an object that links a message with the action to execute in the update function. Doing this inables us to invoke these from the view. In our previous example we used hyphenated strings as the message type: "add-item", "delete-item", etc. Now we're going to change those to unhaphyenated names that we can use as a function: "AddItem", "DeleteItem". We'll use these as the values for creating a tagged union. In versions prior to 1.4.0 the arguments are enclosed in an array. But now you just pass in however arguments you need to, separated by commas:
const Msg = new union('AddItem', 'DeleteItem')
The above creates a tagged union called Msg
that we can use to associate our update actions
with. This will allow us to send messages in our view as without creating objects. To do this we can use
destructuring to capture the methods from the tagged union and then use those to send. Although we are dealing
with destructured methods, we don't have to execute them with parens. We just provide their name, and any
secondary data as a payload. The send function checks to see if they are functions and invokes them for us
automatically, passing them any data we provide:
const Msg = new union('AddItem', 'DeleteItem')
// Destructure the tagged union methods:
const {AddItem, DeleteItem} = Msg
// Send those messages with some data:
send(AddItem, 'Watermelon')
send(DeleteItem, 132)
Here's how we would use them in our view markup:
// To add an item:
<button class='add-item' onclick={() => send(AddItem, inputValue)}>Add</button>
// To delete an item:
<button class="deleteItem" onclick={() => send(DeleteItem, item.key)}>X</button>
We could also invoke the destructure methods while sending them, passing the data as their argument:
send(AddItem('Watermelon'))
send(DeleteItem(132))
Compare the tagged union way with sending messages as objects:
// To add an item:
<button class='add-item' onclick={() => send({type: 'add-item', data: inputValue})}>Add</button>
// To delete an item:
<button class="deleteItem" onclick={() => send({type: 'delete-item', data: item.key})}>X</button>
Using tagged unions results is tighter code that is easier to read and understand.
Matching Messages to Actions
A tagged union has a match
method. This takes two arguments: a tag to test and handers. When
using a tagged union inside of the udpate
method, the tag to test will be the message passed to
it from a user interaction using the send
function. Handers is an object literal of tags and
functions to execute. When the message sent matches a tag, the corresponding function gets executed.
When we setup the update function, we can check that the message sent matches the values in the tagged union
using the match
method. Since we're using a tagged union, we don't need a switch statement to
check for a tag type msg.type
. This now gets handled by the match
method. This
results in a simpler signature for the actions. We can make an actions
function that takes the
message and state and retuns the result of a match:
import {Msg} from './messages'
function actions(state, msg) {
// Use clone to clone state:
const prevState = clone(state)
return Msg.match(msg, {
// Check for the message tag:
'AddItem': value => {
prevState.fruits.push({ key: state.newKey++, value })
return prevState
},
// Check for the message tag:
'DeleteItem': key => {
prevState.fruits = prevState.fruits.filter(item => item.key != key)
return prevState]
}
})
}
// As opposed to:
function actions(state, msg) {
// Use clone to clone state:
const prevState = clone(state)
switch (msg.type) {
case 'add-item':
prevState.fruits.push({ key: prevState.newKey++, msg.inputValue })
return prevState
break
case 'delete-item':
prevState.fruits = prevState.fruits.filter(item => item.key != msg.key)
return prevState
break
}
}
The above example should make it clear why to use a tagged union. It makes the code cleaner and easier to reason about. Using a tagged union in the view makes the intent of the event clearer. They imply that they will invoke a corresponding update action. Without tagged unions, the events in our view are senging message objects off to an unspecified destination. We rely on our understanding of how the runtime works to assume that they will be intercepted by update actions. Tagged unions make the intent of view events clearer, and the update actions more compact.
Default or CatchAll
In the previous actions example we were checking each possible tag and providing a function to execute. In a
switch statement we can use default
to use when there is no match. We can also do that with a
tagged union. Just provide a function as the last argument after the handlers object:
function actions(msg, state) {
// Use clone to clone state:
const prevState = clone(state)
return Msg.match(msg, {
// Check for the message tag:
'AddItem': value => {
prevState.fruits.push({ key: state.newKey++, value })
return prevState
},
// Check for the message tag:
'DeleteItem': key => {
prevState.fruits = prevState.fruits.filter(item => item.key != key)
return prevState
}
},
// Provide default action if no matching message:
() => {
console.log('There was no match.')
})
}
Below is a complete working example of using a tagged union for setting up actions and sending them from the view.
See the Pen @composi/core + runtime + union by Robert Biggs (@rbiggs) on CodePen.