I'm still playing around with Redux and, as usual, I'm always on the lookout for ways to optimize my laziness.
One thing that I found irks me just a little bit are the Redux
actions. They are nothing but raw Javascript objects, meaning they
are very easy to set up and manipulate. But since anything goes, they
are also very easy to subtly get wrong. For example, I'm working on a
spaceship game and I have an action called MOVE_SHIP
. But what
arguments was I using for that? Was it this:
{ type: 'MOVE_SHIP', ship: 'enkidu' }
or rather, that:
{ type: 'MOVE_SHIP', ship_id: 'enkidu' }
Sometimes, I remember to double check myself, but other times, I'll use the wrong property and set myself up for a long, protracted, somewhat less-than-joyful debugging session.
Ready? Set... (some expectations)
There is also the creation of the actions themselves. While it's not hard to do:
let action = { type: 'MOVE_SHIP', ship_id: the_ship };
it is more verbose than it needs to be. When we move a ship, we'll always need to have the ship id — or if we're fancy, we could also take a ship object and then extract the id from that. In a better world, I'd love to just do:
let action = action_move_ship( the_ship );
// action === { type: 'MOVE_SHIP', ship_id: the_ship }
(By the by, Redux-Actions already provides a mechanism to do just that. All in all, It's pretty close to what I want, but doesn't go far enough down the DWIM rabbit hole for my taste, nor does it allow for some more esoteric functionality that will pop up in a few paragraphs.)
Finally, there is the declaration of the action types, which is usually distinct from the creation of the actions themselves. The types are either used as ad-hoc strings, or as defined constants. Often like:
const MOVE_SHIP = 'MOVE_SHIP';
const ADD_SHIP = 'ADD_SHIP';
// later on...
function reducer( state, action ) {
switch( action.type ) {
case MOVE_SHIP: ...;
case ADD_SHIP: ...;
}
}
It's not bad, but the neat freak in me wishes that those constants were
gathered together, and somewhat tied to the action generators. Because,
honestly, I don't want to type MOVE_SHIP
more than once. (Yes, I am that
lazy.)
So, to recap, I'd like to:
Have a unified, centralized way to declare both the action types and their generators.
Be able to customize the creators however I want (while having a useful default).
Having some type of validation on the payload of those actions would also be fantastic.
ACTIONS!
To scratch those itches, I'd like to introduce you toActioner.
First, the ultra-basic way to use it with vanilla actions:
import Actioner from 'actioner';
let Actions = new Actioner();
Actions._add( 'move_ship' );
Actions._add( 'add_ship' );
console.log( Actions.MOVE_SHIP ); // prints 'MOVE_SHIP'
let action = Actions.add_ship( { id: 'enkidu', hull: 9 } );
// action === { type: 'MOVE_SHIP', id: 'enkidu', hull: 9 }
So, basically, _add()
ing an action creates both the type name and
the action creator, both accessible as keys from the main Actioner
object. Simple enough. Oh, and don't worry, you can also define your
action as the more Javascript-ish moveShip
, and the type will still
be expanded as MOVE_SHIP
.
Next step: _add()
also accepts a custom function that modifies input
parameters in the resulting action object. We could, for example, rewriteMOVE_SHIP
as:
// 'type' property is automatically added, natch
Actions._add( 'move_ship', ship => ({
( typeof ship === 'object' ) ? ship.id : ship
}) );
let action = Actions.move_ship( 'enkidu' );
// action === { type: 'MOVE_SHIP', ship_id: 'enkidu' }
let ship = { id: 'siduri', hull: 9 };
let action = Actions.move_ship( ship );
// action === { type: 'MOVE_SHIP', ship_id: 'siduri' }
What if you want that custom creator for most cases but still want
the possibility to pass a straight-up object? Never fear, in addition
to move_ship
, the sibling function $move_ship
, which always
accepts raw objects, is also created:
// equivalent to the call to 'move_ship' above
let action = Actions.$move_ship( { ship_id: 'siduri' } );
And final feature: validation. For that, I went with my very ownjson-schema-shorthand library for json-schema.
new Actions = new Actioner({ schema_id: 'http://example.com/actions' });
Actions._add( 'add_ship', {
ship_id: { type: 'string', required: true },
hull: { type: 'number', required: true },
additional_properties: false,
});
Actions._validate(true);
Actions.add_ship({ ship_id: 'enkidu' }); // will throw
let schema = Actions._schema();
// schema === {
// id: 'http://localhost/actions',
// oneOf: [ { '$ref': '#/definitions/add_ship' } ]
// definitions: {
// add_ship: {
// type: 'object',
// properties: {
// type: { enum: [ 'ADD_SHIP' ] },
// ship_id: { type: 'string' },
// hull: { type: 'number' },
// },
// additional_properties: false,
// required: [ 'ship_id', 'hull' ],
// }
// }
Did I say "final feature"? Well, that was the end of the hard requirements,
but there are still a few goodies embedded in there. Like the security that
comes with immutable objects? You'll love that you can have your actions
be set as immutable viaseamless-immutable,
just by passing the immutable
option to the Actioner constructor:
new Actions = new Actioner({ immutable: true });
Actions._add( 'FOO' );
let action = Actions.foo({ bar: 1 }); // action is immutable
And then there is the possibility to connect the object to a Redux store, and to be able to create and dispatch actions in one fell swoop.
Actions._store = aReduxStore;
Actions.dispatch_foo(); // equivalent to aReduxStore.dispatch( Actions.foo() )
Actions.dispatch_$foo(); // equivalent to aReduxStore.dispatch( Actions.$foo() )
dispatch( 'FINAL_WORDS' )
So, all in all, this little helper library is nothing out of this world. But having those declarations centralized, and most importantly preemptively validated, is something that does wonders to keep me honest, or at least consistent, as I hack away. Even better, by using the JSON schema validation, I also plant the seeds of documentation, something that future-me will doubtlessly thanks present-me for.
As always, if you are intrigued, the code is onGitHub and available via npm.