Another scaffolding for React Redux, but with strong typed classes, object oriented and specially handcrafted for Microsoft TypeScript
npm install redux-scaffolding-tsredux-scaffolding-ts provides an easy way to use React Redux and is specially crafted for Microsoft TypeScript. It is not a replacement of redux but a scaffolding built over the existing redux library using some conventions.
typescript
redux-scaffolding-ts is inspired on these existing libraries, but has a different goal: to ease the plumbing required
npm install redux-scaffolding-ts --save
`
It is very important that you enable experimental decorators and emit metadata in typescript tsconfig.json. Example:
`javascript
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"outDir": "lib",
"moduleResolution": "node",
"jsx": "react",
"lib": [ "es6", "dom" ],
"sourceMap": false,
"inlineSourceMap": true,
"declaration": true,
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
`
Finally, dont forget to include reflect metadata package on your bootstrap code:
`typescript
import 'reflect-metadata';
`
$3
Let's start with a classic example: a counter.
First of all, will define the form that will have the state.
`typescript
export interface CounterState {
count: number
}
`
Since we are using TypeScript, the SimpleState interface will help us to model our state and
will prevent typing errors when changing the sate in the reducer.
Then will add a Repository. This concept is introduced by react-scaffolding and is just
a way, using object-oriented programming, of handling specific actions and just update a secition
of the application state. This follows the separation of concerns principe: one repository one single responsability.
`typescript
import { repository, reduce, ReduxRepository } from 'redux-scaffolding-ts'
@repository("@@COUNTER", "counter")
export class CounterRepository extends ReduxRepository {
public static readonly COUNT_INCREASED = "COUNT_INCREASED";
constructor() {
// Initial state
super({ count: 0 });
}
public increase(amount?: number) {
this.dispatch(CounterRepository.COUNT_INCREASED, amount || 2);
}
@reduce(CounterRepository.COUNT_INCREASED)
protected onIncrease(amount: number): CounterState {
return { ...this.state, count: this.state.count + amount };
}
}
`
As you can see, the @repository decorator is used to define the namespace of the actions that this
repository will handle (@@COUNTER/*) and the branch of the sate that will change, in this case counter.
All actions must be named with format @@NAMESPACE/ACTION_NAME_[RESULT]. The namespace will be used to
correctly route the action to its specific repository. It is recommended to use past tense for naming actions.
@reduce decorator is used to link a reducer (class method) to a specific action. It is recommended to define
actions as class properties because will help automatic refactoring if the IDE supports it, and also can export
actions names to beign used in other components like sagas.
`typescript
import { connect } from 'redux-scaffolding-ts'
type CounterComponentProps = {
// Any component prop you want to define
};
@connect(["counter", CounterRepository])
class CounterComponent extends React.Component {
private get counter() {
return (this.props as any).counter as CounterRepository;
}
constructor(props: CounterComponentProps) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
private handleClick() {
this.counter.increase(1);
}
render() {
return
{this.counter.state.count}
;
}
}
`
The decorator connect will automatically generate the mapStateToProps and mapDispatchToProps by convention.
You only need to specify the storeBuilder, the Repository class and the name of the property under props
you want to use to connect the repository. Since repositories are singleton, this API
will do the job.
To access the repository is recommended to create a readonly property called as specified in the connect decorator (in this case get counter()).
Finally, the configureStore.ts content:
`typescript
import { storeBuilder } from 'redux-scaffolding-ts'
// Global application state
export interface ApplicationState {
counter: CounterState
}
export default function configureStore(initialState?: ApplicationState): Store {
storeBuilder.addRepository(new CounterRepository());
return storeBuilder.getStore(initialState);
}
`
$3
Suppose you have a Promise, for instance, AJAX call to server. redux-saffolding has a convention
to easily handle async actions:
`typescript
public getData() {
return this.dispatchAsync("GET_DATA", new Promise((resolve, reject) => {
// Simulates server call
setTimeout(() => resolve(["Hello", "World"]), 100);
}));
}
@reduce("GET_DATA")
protected onGetData(): AsyncAction {
return {
onStart: (args) => ({ isBusy: true }),
onSuccess: (result, args) => ({ isBusy: false, items: value }),
onError: (error, args) => ({ isBusy: false, error: true })
}
}
`
This example uses a Promise object, but you can use the new async/await syntax. The idea is that
you use the dispatchAsync method, passing the promise as a parameter. dispatchAsync is also awaitable.
The async reducer has return type AsyncAction. TResult must match the promise result
type and TState the state. Each member, onStart, onSuccess and onError will trigger on each
corresponding step of the promise.
One important convention is that on each step, a new action will be automatically dispatched, with _START, _SUCCESS
or _ERROR. Following this example:
- onStart will dispatch GET_DATA_START with { isBusy: true}.
- onSuccess will dispatch GET_DATA_SUCCESS with { isBusy: false, items: ["Hello", "World"]}
- onError will dispatch GET_DATA_ERROR with { isBusy: false, error: true}
$3
Sagas in redux-scaffolding-ts are very basic. If you need strong sagas, please use redux-saga.
Sagas help to manage side effects (like loading data asynchronoulsy) but also can become a Process Manager
in big business transactions or workflows.
#### Simple saga
Since the introduction of JavaScript Generators, sagas become more clean to write and understand.
Also, are more testable because you can control step-by-step each iteration of the generator.
Let's model the previous AsyncAction (getData) using a saga.
`typescript
public getData() {
return this.dispatch("GET_DATA");
}
@saga("GET_DATA")
private async *onGetData(control: SagaControl, ...args: any[]) {
const { wait, update, dispatch } = control;
let state: any = yield;
yield update("START", { ...state, isBusy: true } as ListState);
try {
// Simulates server call
var values = await new Promise((resolve, reject)=>{
resolve(["Hello", "World"])
});
yield update("SUCCESS", { ...state, items: values, } as ListState);
} catch {
yield update("ERROR", { ...state, error: true } as ListState);
}
finally {
yield update("DONE", { ...state, isBusy: false } as ListState);
}
}
`
As you can see, there are contol functions (wait, update, dispatch) that either wait for other actions,
change the current state or dispatch new actions. By convention the @@NAMESPACE and the action name (GET_DATA)
will be prepended, forming, for instance @@NAMESPACE/GET_DATA_START global action.
Sagas have more freedom and can dispatch global actions or wait for actions dispatched from other repositories.
Just use the full notation like will be shown on the next example.
#### Complex saga
Suppose that you already have some repositories to make a reservation of an hotel or a flight. However,
to book a vacation, you need to book a hotel and a flight, hence two reservation codes. If some reservation
fail then the other must be also cancelled.
`typescript
@repository("@@COMPLEX_SAGA", "complexSaga")
export class ComplexSagaRepoDemo extends ReduxRepository {
constructor() {
super({ isBusy: false, hotelBookingCode: "", flightBookingCode: "", succeed: false });
}
public bookVacation(clientId: number) {
return this.dispatch("BOOK_VACATION", clientId);
}
@saga("BOOK_VACATION")
private async *onBookingStart(control: SagaControl, clientId: number) {
const { wait, update, dispatch } = control;
let state: ComplexSaga = yield update("BUSY", { isBusy: true });
// Dispatch hotel reservation to another repository
yield dispatch("@@RESERVATION/BOOK_HOTEL", clientId, "Hotel info");
// Wait response from hotel reservation repository
let result = yield wait("@@RESERVATION/BOOK_HOTEL_SUCCESS", "@@RESERVATION/BOOK_HOTEL_FAILED");
switch (result.type) {
case '@@RESERVATION/BOOK_HOTEL_SUCCESS':
// If hotel reservation succeed, then update reservation code in state
state = yield update("HOTEL_SUCCESS", { hotelBookingCode: result.payload.bookingCode } as ComplexSaga)
break;
case "@@RESERVATION/BOOK_HOTEL_FAILED":
// If hotel reservation fails, then set isBusy=false and break the workflow
yield update("HOTEL_FAILED", { })
yield update("BUSY", { isBusy: false } as ComplexSaga);
return;
}
// Dispatch flight reservation (Hotel is already booked)
yield dispatch("@@RESERVATION/BOOK_FLIGHT", clientId, "Flight info");
result = yield wait("@@RESERVATION/BOOK_FLIGHT_SUCCESS", "@@RESERVATION/BOOK_FLIGHT_FAILED")
switch (result.type) {
case '@@RESERVATION/BOOK_FLIGHT_SUCCESS':
// If flight reservation succeed, then update reservation code in state
state = yield update("FLIGHT_SUCCESS", { succeed: true, flightBookingCode: result.payload.bookingCode } as ComplexSaga)
break;
case "@@RESERVATION/BOOK_FLIGHT_FAILED":
// If hotel reservation fails, then set isBusy=false and cancel current hotel reservation
yield dispatch("@@RESERVATION/CANCEL_HOTEL", state.hotelBookingCode);
yield update("FLIGHT_FAILED", { hotelBookingCode: '' })
yield update("BUSY", { isBusy: false } as ComplexSaga);
return;
}
// All reservations go well
yield update("BUSY", { isBusy: false } as ComplexSaga);
}
}
`
IMHO this is the full-power of a saga, not just model async operations. This (simplified) example resembles
a distributed business transactions between multiple repositories.
$3
To connect to multiple repositories, you only need to pass connection information in @connect decorator:
`typescript
@connect(["repo1", FirstRepository], ["repo2", SecondRepository])
class MultiStoreComponent extends React.Component {
private get repo1() {
return this.props.repo1 as FirstRepository;
}
private get repo2() {
return this.props.repo2 as SecondRepository;
}
render() {
...
}
}
`
$3
It is possible to dinamically add reducers to a repository, but only if the store is not connected.
This is usefull in inheritance scenarios when you want to create actions names from the base class using some data from
the derived class.
`typescript
@repository("@@DYNAMIC", "dynamic")
export class DynamicRepoDemo extends ReduxRepository {
constructor() {
super({ count: 0 });
this.addReducer("DYNAMIC_ACTION", (inc: number): CounterState => {
return { ...this.state, count: this.state.count + inc };
}, 'Simple')
}
public action() {
this.dispatch("DYNAMIC_ACTION", 1);
}
}
`
$3
Suppose that you want to integrate the current redux-scaffolding-ts in an existing redux application with an
existing configureStore method. The only thing you need to do is pass your custom createStore and your root reducer to storeBuilder.getStore(...)
`typescript
import { createStore, applyMiddleware, compose, combineReducers, StoreEnhancer, Store, StoreEnhancerStoreCreator, ReducersMapObject } from 'redux';
import thunk from 'redux-thunk';
import { routerReducer, routerMiddleware } from 'react-router-redux';
import * as StoreModule from './stores/store';
import { ApplicationState, reducers } from './stores/store';
import { History } from 'history';
import { storeBuilder } from 'redux-scaffolding-ts';
export default function configureStore(history: History, initialState?: ApplicationState) {
// Build middleware. These are functions that can process the actions before they reach the store.
const windowIfDefined = typeof window === 'undefined' ? null : window as any;
// If devTools is installed, connect to it
const devToolsExtension = windowIfDefined && windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__ as () => StoreEnhancer;
const createStoreWithMiddleware = compose(
applyMiddleware(thunk, routerMiddleware(history)),
devToolsExtension ? devToolsExtension() : (next: StoreEnhancerStoreCreator) => next
)(createStore);
// Combine all reducers and instantiate the app-wide store instance
const rootReducer = buildRootReducer(reducers);
const store = storeBuilder.getStore(initialState, rootReducer, createStoreWithMiddleware as any);
// Enable Webpack hot module replacement for reducers
if (module.hot) {
module.hot.accept('./stores/store', () => {
const nextRootReducer = require('./stores/store');
store.replaceReducer(buildRootReducer(nextRootReducer.reducers));
});
}
return store;
}
function buildRootReducer(allReducers: ReducersMapObject) {
return combineReducers({ ...allReducers as any, router: routerReducer });
}
``