clarus / redux-ship
- среда, 26 октября 2016 г. в 03:15:57
JavaScript
Scalable, testable and typable side effects for Redux
Scalable, testable and typable side effects for Redux
Redux Ship is a side effects handler for Redux which focuses on:
See a demo with reusable components and shared state.
Redux Ship with redux-ship-logger:
Run:
npm install --save redux-ship
You can optionally install Flow to get type checking and redux-ship-logger to get logging.
Redux Ship is based on the Model–view–controller and the Flux architecture.
This architecture is composable and applies both to the whole application and to each component.
The current state of the component / application, handled by Redux. We modify the model by applying serializable patches.
The HTML displayed by the component / application, handled by React. Updates automatically when the model changes. We only have dumb components (without logics). The view dispatches serializable actions to the controller in response to user events.
Manages side effects like interactions with the server. Written with Redux Ship. The controller handles an action by calling some side effects and by emitting some serializable commits to the model. A commit may be formed of one or several patches if it is destined to one or several models. Think commits in Git which can have several patches on different files. A Ship controller is implemented as a generator and each execution is serializable as a snapshot.
You might not need Redux Ship, especially for small projects. Here is an opinionated comparison of Redux Ship with some alternatives.
Redux Thunk | Redux Sagas | Elm | Redux Ship | |
---|---|---|---|---|
scalability | ~ | ~ | ~ | ✔ |
testing | ~ | ✔ | ~ | ✔ |
snapshots | - | ? | - | ✔ |
typing | ✔ | ~ | ✔ | ✔ |
map
primitive and the commit / patch mechanism, Redux Ship aims to offer a built-in solution to compose components with both a local and a shared state.Testable.Cmd
instead of the standard Cmd
.snap
primitive to take the snapshot of a live execution, and simulate
to check it afterwards.const state = yield select(selector);
we cannot get the type of answer
. This limitation is due to the use of the yield
keyword in the generators. In contrast, in Redux Ship, we only use the yield*
keyword to get full typing.Ship<Effect, Commit, State, A>
Snapshot<Effect, Commit>
all
call
commit
getState
map
run
simulate
snap
Ship<Effect, Commit, State, A>
The type of a ship. A ship is a generator and can be defined using the function*
syntax.
Effect
the type of the side effects the ship can call. We often use a single Effect
type for a whole programCommit
the type of the commits the ship can commitState
the type of the state as visible from the shipA
the type of the value returned by the ship (often void
)A controller to load a random gif:
export function* control(action: Action): Ship.Ship<*, Commit, State, void> {
switch (action.type) {
case 'Load': {
yield* Ship.commit({
type: 'LoadStart',
});
const result = yield* Effect.httpRequest(
`http://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=${action.tag}`
);
const gifUrl: string = JSON.parse(result).data.image_url;
yield* Ship.commit({
type: 'LoadSuccess',
gifUrl,
});
return;
}
default:
return;
}
}
Snapshot<Effect, Commit>
The type of the snapshot of an execution of a ship. A snapshot includes the side effects ran by a ship, as well as their execution order (sequential or concurrent). You can take a snapshot with the redux-ship-logger dev tools or with the snap
function.
Effect
the type of effects in the snapshotCommit
the type of commits in the snapshotThe snapshot of a controller loading a random gif:
[
{
"type": "Commit",
"commit": {
"type": "LoadStart"
}
},
{
"type": "Effect",
"effect": {
"type": "HttpRequest",
"url": "http://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=minion"
},
"result": [...]
},
{
"type": "Commit",
"commit": {
"type": "LoadSuccess",
"gifUrl": "http://media3.giphy.com/media/HyanD1KpfzPiw/giphy.gif"
}
}
]
all
<Effect, Commit, State, A>(
ships: Ship<Effect, Commit, State, A>[]
) => Ship<Effect, Commit, State, A[]>
Returns the array of results of the ships
by running them in parallel. This is the equivalent of Promise.all
in Ship.
ships
an array of ships to execute concurrentlyIf you have a fixed number of tasks with different types of result to run in parallel, you can use:
all2(ship1, ship2)
all3(ship1, ship2, ship3)
...
all7(ship1, ship2, ship3, ship4, ship5, ship6, ship7)
To concurrently get three random gifs:
const gifUrls = yield* Ship.all(['cat', 'minion', 'dog'].map(function* (tag) {
const result = yield* Effect.httpRequest(
`http://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=${tag}`
);
return JSON.parse(result).data.image_url;
}));
call
<Effect, Commit, State>(
effect: Effect
): Ship<Effect, Commit, State, any>
Calls the serialized effect effect
. The type of the result is any
because it depends on the value of the effect.
effect
the effect to callTo prevent type errors, we recommend to wrap your calls to call
with one wrapper per kind of effect. For example, if you have an effect HttpRequest
which always returns a string
:
export function httpRequest<Commit, State>(url: string): Ship<*, Commit, State, string> {
return Ship.call({
type: 'HttpRequest',
url,
});
}
commit
<Effect, Commit, State>(
commit: Commit
): Ship<Effect, Commit, State, void>
Commits a commit of type Commit
and waits for its termination.
commit
the commit to applyTo commit the result of a successful HTTP request:
yield* Ship.commit({
type: 'LoadSuccess',
gifUrl,
});
getState
<Effect, Commit, State, A>(
selector: (state: State) => A
): Ship<Effect, Commit, State, A>
Returns a part of the current state by applying a selector.
selector
a selector to extract the useful part of the current stateTo get the current gif in the store:
const currentGif = yield* Ship.getState(state => state.randomGif.gifUrl);
map
<Effect, Commit1, State1, Commit2, State2, A>(
liftCommit: (commit: Commit1) => Commit2,
extractState: (state2: State2) => State1,
ship: Ship<Effect, Commit1, State1, A>
): Ship<Effect, Commit2, State2, A>
A function useful to compose nested components. Lifts a ship
with access to "small set" of commits Commit1
and a "small set" of states State1
to a ship with access to the "larger sets" Commit2
and State2
. This function iterates through the ship
and replace each getState()
by getState(state => selector(extractState(state)))
and each commit(commit1)
by commit(liftCommit(commit1))
.
liftCommit
lifts a local commitextractState
extract the local stateship
the ship to mapTo lift a controller to retrieve one random gif to a controller to retrieve two random gifs:
return yield* Ship.map(
commit => ({type: 'First', commit}),
state => ({
counter: state.counter,
randomGif: state.randomGifPair.first,
}),
RandomGifController.control(action.action)
);
run
<Effect, Commit, State, A>(
runEffect: (effect: Effect) => any,
runCommit: (commit: Commit) => void | Promise<void>,
runGetState: () => State,
ship: Ship<Effect, Commit, State, A>
) => Promise<A>
Runs a ship and its side effects by evaluating each call
, commit
and getState
with runEffect
, runCommit
and runGetState
respectively.
runEffect
the function to evaluate a serialized side effect (usually returns a promise)runCommit
the function to apply a commit to the staterunGetState
the function to get the current global stateship
the ship to executeTo connect Redux Ship to a Redux store
, you can do:
Ship.run(runEffect, store.dispatch, store.getState, ship);
where runEffect
is your function to evaluate your side effects:
export type Effect = {
type: 'HttpRequest',
url: string,
};
export async function run(effect: Effect): Promise<any> {
switch (effect.type) {
case 'HttpRequest': {
const response = await fetch(effect.url);
return await response.text();
}
default:
return;
}
}
simulate
<Effect, Commit, State, A>(
ship: Ship<Effect, Commit, State, A>,
snapshot: Snapshot<Effect, Commit, State>
): Snapshot<Effect, Commit, State>
Simulates a ship
in the context of a snapshot
and returns the snapshot of the simulation. A simulation is a purely functional (with no side effects) execution of a ship. Since there are many ways to execute a ship, we need a snapshot a previous live execution of the ship (with side effects). For example, if the ship runs an API request, the snapshot is used to give an answer to the API request. The result of simulate
should be equal to snapshot
, unless your ship was changed since its snapshot was taken.
ship
the ship to simulatesnapshot
a snapshot of a previous execution of the shipIn a unit test of a controller:
expect(Ship.simulate(control(action), snapshot)).toEqual(snapshot);
snap
<Effect, Commit, State, A>(
ship: Ship<Effect, Commit, State, A>
) => Ship<Effect, Commit, State, {
result: A,
snapshot: Snapshot<Effect, Commit, State>
}>
Returns a ship taking the snapshot and returning the result of ship
. You can also get snapshots of your controllers with redux-ship-logger.
ship
the ship to take in pictureTo take the snapshot of a ship:
const {result, snapshot} = yield* snap(ship);
To take the snapshot of a controller (the result
should always be undefined
):
const {snapshot} = yield* snap(control(action));
MIT