Two kinds of problems
1. Immediate, atomic changes
...e.g. Form fields, buttons, pure UI
Our Solution
- Global state is easy to debug
- Components still treat state as if it's local
- Components are composable without knowing the global tree's structure
Global state, cursors
2. Sequences of
coordinated changes
....e.g. HTTP requests, business rules, derived state
Our Solution
- Operates on global state atom (getState)
- Promises handle sequencing via .then
Events + handlers + promises
Um, what's the problem?
function setPricingContextAsync (structure) {
let cState = structure.cursor();
cState = cState.set('isGettingRates', true);
updateMarketEstimateAsync(structure);
return executePeriodicAsync(() => updateRatesAsync(structure), { interval: 1000, times: 2 }).then(() => {
let cState = structure.cursor();
// If polling returned any rates, turn off the loading indicator
if (cState.get('rates').count()) {
cState = cState.set('isGettingRates', false);
cState = updateActiveView(structure);
}
// Continue to poll for rates in the background
return executePeriodicAsync(() => updateRatesAsync(structure), { interval: 2000, times: 2 });
}).always(() => {
let cState = structure.cursor();
// Always turn off the loading indicator when polling is finished
cState = cState.set('isGettingRates', false);
// Check if polling returned any rates, and transition if there are none
return cState = updateActiveView(structure);
});
}
Bad and not fun
Inherent complexity
1. (sync) Set 'isUpdating' flag
2. (sync) Update incomplete listing
3. (async) create a new RateRequest
4a. (aysnc) request rates
a1. poll for rates
1a. (async) fetch rates
1b. (sync) update with results
1c. (async) wait 1 second
(repeat 4x)
a2. (sync) maybe set isUpdating flag
a3. poll for rates
3a. (async) fetch rates
3b. (sync) update with results
3c. (async) wait 2 second
(repeat 4x)
a4. (sync) maybe set isUpdating flag
4b. (async) request estimate
b1. (sync) set 'isRequestingEstimate' flag
b2. (async) fetch estimate
b3. (sync) update with estimate
Complexity of expression
Does adding a requirement or step feel like a linear change, or a logarithmic change?
Verifiability
-
How hard is it to test?
-
Can you write tests first?
-
Does changing it feel safe, or scary?
What can we do about it?
SAGA
(stolen from CQRS/Event Sourcing)
A long-running state machine that is driven by events
What does it look like?
import { take, call, fork, update } from '../../components/sagas/io';
import { DATE_RANGE_CHANGED } from '../constants/actions';
import serializeListing from '../effects/serializeListing';
import UpdateCommand from '../commands/UpdateIncompleteListingCommand';
function setDateRange (state, routeStop, range) {
return state.setIn([routeStop, 'earliestArrival'], range.start)
.setIn([routeStop, 'latestArrival'], range.end);
}
function* timeframeSaga () {
while (true) {
const { dateRange, datepickerId, state } = yield take(DATE_RANGE_CHANGED);
const range = dateRange;
const routeStop = getRouteStop(datepickerId);
if (!rangeWasUpdated(state, routeStop, range)) continue;
const updatedState = yield update(setDateRange, routeStop, range);
const serializedModel = yield call(serializeListing, updatedState);
yield fork(UpdateCommand.runAsync, serializedModel);
}
}
Saga.withAtom(structure)
.run(timeframeSaga);
Two key concepts
1. Declarative effects
"Things that happen" are just plain JS objects, which means they're super testable
// describes pausing until FOO_ACTION occurs
const takeEffect = take(FOO_ACTION);
expect(takeEffect).to.deep.equal({
TAKE: true,
pattern: FOO_ACTION
});
// describes calling the function fetchSomething with argument 'id'
const callEffect = call(fetchSomething, id);
expect(takeEffect).to.deep.equal({
CALL: true,
fn: fetchSomething,
args: id
});
2. Inversion of control
function* persistenceSaga (model) {
// Yields control with a 'call' effect description
// Process runner runs the call, and returns control with the result
const serializedModel = yield call(serialize, model);
try {
yield call(updateModelAsync, serializedModel);
} catch (e) {
// even logging is a side effect
yield call(log, `Update failed with error ${e.message}`);
}
}
Isolates the actual running of side-effects in one place, which makes declarative effects possible
1. (sync) Set 'isUpdating' flag
2. (sync) Update incomplete listing
3. (async) create a new RateRequest
4a. (aysnc) request rates
a1. poll for rates
1a. (async) fetch rates
1b. (sync) update with results
1c. (async) wait 1 second
(repeat 4x)
a2. (sync) maybe set isUpdating flag
a3. poll for rates
3a. (async) fetch rates
3b. (sync) update with results
3c. (async) wait 2 second
(repeat 4x)
a4. (sync) maybe set isUpdating flag
4b. (async) request estimate
b1. (sync) set 'isRequestingEstimate' flag
b2. (async) fetch estimate
b3. (sync) update with estimate
Remember this?
yield update(setIsUpdating, true)
yield update(applyShipmentEdits, edits)
yield call(createRateRequest, state)
yield fork(requestRates* () {
yield callRepeated(4, pollForRates* () {
yield call(fetchRates, rateRequestId)
yield update(setRates, rates)
yield wait(1000)
if (rates) yield update(setIsUpdating, false)
yield callRepeated(pollForRates* () {
yield call(fetchRates, rateRequestId)
yield update(setRates, rates)
yield wait(2000)
yield update(setIsUpdating, false)
yield fork(requestEstimate* () {
yield update(isRequestingEstimate, true)
yield call(fetchEstimate, state)
yield update(setEstimate, estimate)
Maps almost 1:1
Concerns
- ANOTHER concept???
- Generators are hard to grok
- Uncanny valley resemblance to CSP
- What else?
Sagas
By Daniel Poindexter
Sagas
- 561