Redux-typed-saga is an alternative, well-typed take on the awesome redux-saga.
The inspiration for typing side effects came from Redux Ship. However, Redux Ship has a totally different paradigm, and I don't want to buy into that completely.
This 🚧 experimental project aims to rethink redux-saga with types as first-class citizens.
function* saga(): Saga<Effect, Action, State, void> {
const num = yield* select(x => x + 10);
yield* put(set(50));
const action = yield* take(x => x.type === 'SET' ? x : null);
// effects are fully user-defined
yield* Effects.wait(1);
const response = yield* Effects.httpRequest({ url: 'http://example.com' });
}
npm install --save redux-typed-saga@https://github.com/goshakkk/redux-typed-saga.git
import { createSagaMiddleware, select, put, take } from 'redux-typed-saga';
import type { Saga, SagaMiddleware } from 'redux-typed-saga';
function* saga(): Saga<Effect, Action, State, void> {
...
}
const sagaMiddleware: SagaMiddleware<State, Action> = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(runEffect, saga());
The sagas will generally have a return type of Saga<Effect, Action, State, any>
where:
Effect
is the type of possible side effects, similar to Redux Ship;Action
is the type of Redux actions;State
is the type of Redux state;any
is the return type of the saga. Top-level sagas don't usually have a return type, soany
orvoid
it is.
The type of middleware is pretty self-explanatory: SagaMiddleware<State, Action>
.
Commands are called using yield*
, as opposed to yield
in redux-saga.
The reason for this is: typing.
yield
s are painful to type, therefore there are properly typed wrappers for commands.
-
put<Effect, Action, State>(action: Action): Saga<Effect, Action, State, void>
Works exactly as in redux-saga.
-
select<Effect, Action, State, A>(selector: (state: State) => A): Saga<Effect, Action, State, A>
Again, just like in redux-saga, it accepts a selector of type
State => A
, and returnsA
. -
take<Effect, Action, State, A>(actionMatcher: (action: Action) => ?A): Saga<Effect, Action, State, A>
This one is different, however. The
take('ACTION_TYPE')
syntax is impossible to type correctly, and returningAction
isn't nice.To work around that,
take
instead accepts a matcher function, that takes in anAction
, and maybe returns some typeA
, which is usually:- a type of single action, or
- a disjoint union type, if you're matching several actions
If
null
is returned, the action is not matched, and we're waiting for other actions.There actually are two common uses for
take
in redux-saga:take('SOME_ACTION_TYPE')
. Its counterpart in redux-typed-saga istake(x => x.type === 'SOME_ACTION_TYPE' ? x : null)
, and the return type will be an object type for that action.take(['ACTION1', 'ACTION2'])
. Its counterpart in redux-typed-saga istake(x => x.type === 'ACTION1' || x.type === 'ACTION2' ? x : null)
, and the return type will be a disjoint union of these two action types.
It is a bit more verbose, but in return, it makes your project easier to type correctly.
-
call<Effect, Action, State, A>(effect: Effect): Saga<Effect, Action, State, any>
This one is different from the one provided by redux-saga.
Inspired by Redux Ship, the
call
command allows for evalution of arbitrary side effects.To actually apply the effect, redux-typed-saga will call the
runEffect
function that you have to pass tosagaMiddleware.run(runEffect, saga)
. TherunEffect
function has a type of(effect: Effect) => Promise<any>
. -
spawn<Effect, Action, State>(saga: Saga<Effect, Action, State, any>): Saga<Saga, Effect, Action, State, TaskId>
Spawn a saga (task) from within a saga, just like in redux-saga, creates a detached fork. This is similar to calling
sagaMiddleware.run
. -
kill<Effect, Action, State>(saga: Saga<Effect, Action, State, any>): Saga<Saga, Effect, Action, State, TaskId>
Kill a previously spawned saga (task).
-
isDying<Effect, Action, State>(): Saga<Effect, Action, State, bool>
🚧 Detect whether a saga is being killed. This will typically be needed in
finally
to clean up after the saga. However, usingyield*
infinally
is currently broken in Babel / ES spec, as it will terminate the generator completely.
Note this is an early 🚧 prototype. It doesn't really support Redux-saga's process paradigm yet, for one.
type State = number;
type Action = { type: 'INC' } | { type: 'DEC' } | { type: 'SET', value: number };
// SAGAS
import { select, put, take } from 'redux-typed-saga';
import type { Saga } from 'redux-typed-saga';
function* saga(): Saga<Effect, Action, State, void> {
const num = yield* select(x => x + 10);
console.log('+10=', num);
yield* put(set(50));
const action = yield* take(x => x.type === 'SET' ? x : null);
console.log('set', action.value);
console.log('waiting one sec');
yield* wait(1);
console.log('one sec passed');
}
// EFFECTS
type Effect =
{ type: 'wait', secs: number } |
{ type: 'httpRequest', url: string, method: 'GET' | 'POST' | 'PUT', body: ?string };
function runEffect(effect: Effect): Promise<any> {
switch (effect.type) {
case 'wait': {
const { secs } = effect;
return new Promise((resolve, reject) => {
setTimeout(() => reject(secs), secs * 1000);
});
}
case 'httpRequest': {
return fetch(effect.url, {
method: effect.method,
body: effect.body,
}).then(x => x.text());
}
default:
return Promise.resolve();
}
}
function wait<Action, State>(secs: number): Saga<Effect, Action, State, number> {
return call({ type: 'wait', secs });
}
function httpRequest<Action, State>(url: string, method: 'GET' | 'POST' | 'PUT' = 'GET', body: ?string): Saga<Effect, Action, State, string> {
return call({ type: 'httpRequest', url, method, body });
}
// SETUP
import { createSagaMiddleware } from 'redux-typed-saga';
import type { SagaMiddleware } from 'redux-typed-saga';
const sagaMiddleware: SagaMiddleware<State, Action> = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(runEffect, saga());
// REDUCER
function reducer(state: State = 0, action: Action) {
switch (action.type) {
case 'INC':
return state + 1;
case 'DEC':
return state - 1;
case 'SET':
return action.value;
default:
return state;
}
}
// ACTION CREATORS
const inc = () => ({ type: 'INC' });
const dec = () => ({ type: 'DEC' });
const set = (value: number) => ({ type: 'SET', value });
MIT.