📜 ⬆️ ⬇️

Environment for developing TypeScript and React web applications: from 'hello world' to modern SPA. Part 2

The purpose of this article is to write together with the reader an environment for developing modern web applications, consistently adding and customizing the necessary tools and libraries. Similar to numerous starter-kit / boilerplate repositories, but ours is our own.

The article is completely open for revision and correction, and, perhaps, the final material will turn into a relevant and convenient reference book, interesting both for professionals and for those who want to try out new technologies for them.

image

The article does not consider the detailed TypeScript syntax and the basics of working with React, if you do not have experience using the above technologies, it is recommended to separate their study.
')
Link to the first part of the article

The project repository contains the code in separate branches for each step.

The main theme of the 2nd part is the connection and use of the Redux state manager.


The reasons for using Redux, and comparing it with other implementations of the Flux pattern are topics for individual articles, this information is easy to find and study.

I will point out several advantages - a large community and ecosystem, the ability to fully control the behavior of your application, ease of testing, and certain steps towards learning functional programming.

React-redux is a small library that provides us with several React components — the Provider , to transfer Redux storage to the context , and connect , the highest order component to point and transfer data from the storage to the properties of the wrapped component.

Let's go to the code!

Step four - add Redux to the project, basic hello world


To view the final code:

git checkout step-4 

In the src folder, we delete components - examples from step # 3, only index.html and index.tsx remain .

Installation of dependencies (redux includes in the source file a declaration):

 npm install redux react-redux -S npm install @types/react-redux -D 

Change project settings:

In tsconfig.json, we add the moduleResolution: node property so that the compiler finds the declarations defined in the package.json library (in our case redux):

tsconfig.json
 { "compilerOptions": { "lib": [ "es5", "es6", "es7", "dom" ], "target": "es5", "module": "esnext", "jsx": "react", "moduleResolution": "node" } } 


We will create simple actions and a reducer for the future storage, using the methodology of the ducks modules .

In the source folder, create a folder redux to store ducks modules. Inside we create the file field.ts :

field.ts
 /** * State * *       , *      . */ export interface FieldState { value: string; focus: boolean; } const initialState: FieldState = { value: '', focus: false } /** * Constants * *    ,   . *       ,    *        . */ const SET = 'field/SET'; type SET = typeof SET; const FOCUS = 'field/FOCUS'; type FOCUS = typeof FOCUS; const BLUR = 'field/BLUR'; type BLUR = typeof BLUR; /** * Actions * *  Redux  TypeScript,     * ,     (FieldAction)   */ export interface SetAction { type: SET; payload: string; } export interface FocusAction { type: FOCUS; } export interface BlurAction { type: BLUR; } type FieldAction = SetAction | FocusAction | BlurAction; /** * Reducer * *    ,     ,  *      . *  action     ,  *       (FieldAction),   *      ( case SET)   *    action. */ export default function reducer(state: FieldState = initialState, action: FieldAction): FieldState { switch (action.type) { case SET: return { ...state, value: action.payload } case FOCUS: return { ...state, focus: true } case BLUR: return { ...state, focus: false } default: return state; } } /** * Action Creators * *      , *        *  ,       * . */ export const set = (payload: string): SetAction => ({ type: SET, payload }); export const focus = (): FocusAction => ({ type: FOCUS }); export const blur = (): BlurAction => ({ type: BLUR }); 


Add the index.ts file to the redux folder - we will import it into the storage as a root reducer (rootReducer).

redux / index.ts
 import { combineReducers } from 'redux'; import fieldReducer from './field'; export default combineReducers({ field: fieldReducer }) 


Next we will use the tools for developing on Redux - Redux DevTools .
In the source folder we create the store folder, inside the index.ts file:

store / index.ts
 import { createStore } from 'redux'; import rootReducer from '../redux'; import { FieldState } from '../redux/field'; /** *       mapStateToProps, *    ,       * (,     redux-thunk) */ export interface IStore { field: FieldState } /** *          . */ const configureStore = (initialState?: IStore) => { return createStore( rootReducer, initialState, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() ) }; export default configureStore; 


The TypeScript compiler knows nothing about the __REDUX_DEVTOOLS_EXTENSION__ property of the global window object, so it's time to add your own declarations.

Further, we will add global flags to these declarations, which we will transmit via a Webpack, for example __DEV__ or __PRODUCTION__.

In the root folder, create the typings folder, inside the window.d.ts file:

window.d.ts
 interface Window { __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any; __REDUX_DEVTOOLS_EXTENSION__: any; } 


Next, we write a component that receives data from the repository and causes it to be updated. For simplicity, there will be no separation into components and containers. In the source folder we create the components folder, inside the Field.tsx file:

Field.tsx
 import * as React from 'react'; import { connect, Dispatch, DispatchProp } from 'react-redux'; import { IStore } from '../store'; import { set, focus, blur } from '../redux/field'; /** *   DispatchProp ,   dispatch  *   .    connect,    *  ( mapDispatchToProps) */ interface FieldProps extends DispatchProp<IStore>, React.HTMLProps<HTMLInputElement> { value?: string; } class Field extends React.Component<FieldProps, {}> { handleChange = (event: React.FormEvent<HTMLInputElement>) => { const { dispatch } = this.props; const value = event.currentTarget.value; /** *     set  dispatch  *   . */ dispatch(set(value)); } handleFocus = () => { const { dispatch } = this.props; dispatch(focus()); } handleBlur = () => { const { dispatch } = this.props; dispatch(blur()); } render() { const { value, dispatch, ...inputProps } = this.props; return ( <input {...inputProps} type="text" value={value} onChange={this.handleChange} onFocus={this.handleFocus} onBlur={this.handleBlur} /> ); } } /** *   mapStateToProps,   (  ) *        */ const mapStateToProps = (state: IStore, ownProps: FieldProps) => ({ value: state.field.value }); /** *  mapDispatchToProps: * (dispatch: Dispatch<IStore>, ownProps: FieldProps) => ({ ... }) */ /** * connect   ,   10   *   . *  ,   3 : *  mapStateToProps,  mapDispatchToProps,  *   . *  ,       , *         . */ export default connect<{}, {}, FieldProps>(mapStateToProps)(Field); 


And finally, let's collect everything in our application, at the entry point - src / index.tsx :

src / index.tsx
 import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import configureStore from './store'; import Field from './components/Field'; /** *   ,   initialState  * window.__INITIAL_STATE__,       *    . */ const store = configureStore(); /** *          *  DevTools */ const App = () => ( <Provider store={store}> <div> <h1>Hello, Redux!</h1> <Field placeholder='I like dev tools!' /> </div> </Provider> ); ReactDOM.render(<App />, document.getElementById('root')); 


Step Five - Some Typescript Redux Recipes


To view the final code:

 git checkout step-5 

The first recipe is middleware.

In the source folder, create the middlewares folder, inside the logger.ts file (the code is taken from the official documentation ):

middlewares / logger.ts
 import { Middleware, MiddlewareAPI, Dispatch, Action } from 'redux'; import { IStore } from '../store'; /** *    store - MiddlewareAPI<S & IStore> -   *   ,      *    .     , *    middleware  -. */ const logger: Middleware = <S>(store: MiddlewareAPI<S & IStore>) => (next: Dispatch<S>) => //   - <A extends Action>(action: A),   . (action: any) => { //     store.getState().field.value; console.log('dispatching', action); let result = next(action); console.log('next state', store.getState()); return result; } export default logger; 


Update the code to create our repository:

store / index.ts
 import { createStore, compose, applyMiddleware } from 'redux'; import rootReducer from '../redux'; import { FieldState } from '../redux/field'; import logger from '../middlewares/logger'; export interface IStore { field: FieldState } let composeEnhancers = compose; //      middleware const middlewares = [ logger ]; if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; } const configureStore = (initialState?: IStore) => { return createStore( rootReducer, initialState, composeEnhancers( applyMiddleware(...middlewares) ) ) }; export default configureStore; 


The second recipe is a high order reducer.

In the redux folder, create the file createNamedReducer.ts (the code is taken from the official documentation ):

createNamedReducer.ts
 import { Reducer, Action } from 'redux'; import { IStore } from '../store'; /** *      ,  * ,   createNamedReducer */ export interface namedAction extends Action { name: string; } function createNamedReducer<S>(reducer: Reducer<S>, reducerName: string): Reducer<S> { return (state: S, action: namedAction) => { const { name } = action; const isInitializationCall = state === undefined; if (name !== reducerName && !isInitializationCall) { return state; } return reducer(state, action); } } export default createNamedReducer; 


Step Six - work with API


To view the final code:

 git checkout step-6 

Attention! I prefer to make the methods for working with the API into separate services, and bind the data to the repository, calling these methods inside the thunk actions.

But there are libraries such as redux-axios-middleware and redux-api, which are designed to reduce the sample code and create a wrapper for creating http requests.

Therefore, I would like to add this article with your tips and comments on the Redux bundle with the REST API, and in the future to describe in detail the most popular techniques.

For moki API we will use the jsonplaceholder service.

Installation of dependencies (both libraries contain declarations):

 npm install axios redux-thunk -S 

Create a services folder in the source of the project, inside the client.ts and users.ts files :

client.ts
 import axios from 'axios'; /** *        redux * ,       . */ const client = axios.create({ baseURL: 'https://jsonplaceholder.typicode.com' }); export default client; 


users.ts
 import { AxiosPromise } from 'axios'; import client from './client'; //       export interface IUser { id: number; name: string; username: string; email: string; address: any; phone: string; website: string; company: any; } export function get(id: number): AxiosPromise<IUser> { return client.get(`/users/${id}`); } export function getList(): AxiosPromise<IUser[]> { return client.get('/users'); } 


Next, we will create a new ducks module users.ts , just at this stage, many questions arise, and many options for resolving them:

redux / users.ts
 import { Dispatch } from 'redux'; import { IStore } from '../store'; import * as client from '../services/users'; //     type Error = any; //       http  interface AsyncState<D> { isFetching: boolean; error: Error; data: D; } //    ,      interface AsyncAction<P> { status?: 'error' | 'success'; payload?: P | Error; } /** * State */ export interface UsersState { get: AsyncState<client.IUser>; getList: AsyncState<client.IUser[]>; } const initialState: UsersState = { get: { isFetching: false, error: null, data: null }, getList: { isFetching: false, error: null, data: [] } } /** * Constants */ const GET = 'users/GET'; type GET = typeof GET; const GET_LIST = 'users/GET_LIST'; type GET_LIST = typeof GET_LIST; /** * Actions */ export interface GetAction extends AsyncAction<client.IUser> { type: GET; } export interface GetListAction extends AsyncAction<client.IUser[]> { type: GET_LIST; } type UsersAction = GetAction | GetListAction; /** * Reducer * * ,  ! *   ,       *    ,    *   . *     ,     * ,  -   . */ export default function reducer(state: UsersState = initialState, action: UsersAction): UsersState { switch (action.type) { case GET: if (!action.status) { return { ...state, get: { ...state.get, isFetching: true, error: null } } } if (action.status === 'error') { return { ...state, get: { isFetching: false, error: action.payload, data: null } } } return { ...state, get: { isFetching: false, error: null, data: action.payload } } case GET_LIST: if (!action.status) { return { ...state, getList: { ...state.getList, isFetching: true, error: null } } } if (action.status === 'error') { return { ...state, getList: { isFetching: false, error: action.payload, data: [] } } } return { ...state, getList: { isFetching: false, error: null, data: action.payload } } default: return state; } } /** * Action Creators */ export const getActionCreator = ( status?: 'error' | 'success', payload?: client.IUser | Error ): GetAction => ({ type: GET, status, payload, }); export const getListActionCreator = ( status?: 'error' | 'success', payload?: client.IUser[] | Error ): GetListAction => ({ type: GET_LIST, status, payload, }); /** * Thunk Actions */ export function get(id: number) { return async (dispatch: Dispatch<IStore>, getState: () => IStore) => { dispatch(getActionCreator()); try { const response = await client.get(id); dispatch(getActionCreator('success', response.data)); } catch (e) { dispatch(getActionCreator('error', e)); throw new Error(e); } } } export function getList() { return async (dispatch: Dispatch<IStore>, getState: () => IStore) => { dispatch(getListActionCreator()); try { const response = await client.getList(); dispatch(getListActionCreator('success', response.data)); } catch (e) { dispatch(getListActionCreator('error', e)); throw new Error(e); } } } 


Update the rootReducer and the storage interface, add thunk-middleware:

redux / index.ts
 import { combineReducers } from 'redux'; import fieldReducer from './field'; import usersReducer from './users'; export default combineReducers({ field: fieldReducer, users: usersReducer }); 


store / index.ts
 import { createStore, compose, applyMiddleware } from 'redux'; import ReduxThunk from 'redux-thunk' import rootReducer from '../redux'; import { FieldState } from '../redux/field'; import { UsersState } from '../redux/users'; import logger from '../middlewares/logger'; export interface IStore { field: FieldState, users: UsersState } let composeEnhancers = compose; const middlewares = [ logger, ReduxThunk ]; if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; } const configureStore = (initialState?: IStore) => { return createStore( rootReducer, initialState, composeEnhancers( applyMiddleware(...middlewares) ) ) }; export default configureStore; 


Next, write a component that display a list of users, an error message, or a conditional preloader:

Users.tsx
 import * as React from 'react'; import { connect, Dispatch, DispatchProp } from 'react-redux'; import { IStore } from '../store'; import { getList, Error } from '../redux/users'; import { IUser } from '../services/users'; interface UsersProps extends DispatchProp<IStore> { isFetching?: boolean; error?: Error; users?: IUser[]; } class Users extends React.Component<UsersProps, {}> { componentDidMount() { const { dispatch } = this.props; dispatch(getList()); } render() { const { isFetching, error, users } = this.props; if (error) { return <b> !</b> } if (isFetching) { return '...'; } return users.map((user) => <div>{user.name}</div>); } } const mapStateToProps = (state: IStore, ownProps: UsersProps) => ({ isFetching: state.users.getList.isFetching, error: state.users.getList.error, users: state.users.getList.data }); export default connect<{}, {}, UsersProps>(mapStateToProps)(Users); 


Next, simply call the <Users /> component in the root component of our application.

Questions without clear answers:

Do I need to store the request object in the repository, and what advantages can it give? Perhaps this will simplify the cancellation of requests.

What to do when one GET request with dynamic: id in url, use many components on one screen?

A similar problem with asynchronous autocomplete, when different parameters go to one request. You can cache responses, but in such cases, you also need to track the status of each of the requests separately, which requires separate reducers.

Does it make sense to use components that add reducers dynamically, for one specific request, or part of asynchronous data that is used only locally, do not need to be stored in Redux at all?

Let's write a detailed commentary on the article about how we work with the API in our React + Redux applications, and proceed to the next step.

Step Seven - production and development build


To view the final code:

 git checkout step-7 

1) Cross-browser compatibility
Install dependencies:

 npm install core-js -S npm install @types/core-js -D 

Core-js is a library with polyfills of modern JS constructions. Importing the core-js / shim module is almost the same as using the babel-polyfill plugin .

We use only a few required polyfills , add them to the beginning of the entry point into the application:

src / index.ts
 import 'core-js/es6/promise'; import 'core-js/es6/map'; import 'core-js/es6/set'; if (typeof window.requestAnimationFrame !== 'function') { window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(callback, 0); } ... 


In the tsconfig.json file, the "target" property is already listed as "es5", so most polyfills are not necessary. The current build supports IE9 +.

1) Production assembly

At this stage, we need to add the build parameters, change the webpack settings themselves, and send the process.env.NODE_ENV value as a global parameter — some libraries, such as React, use the prod or dev source depending on this parameter.

Install dependencies:

 npm install better-npm-run -D 

better-npm-run - pumps our npm scripts.

Edit npm scripts in package.json , environment variables are very conveniently defined in the “betterScripts” block:

package.json
 { ... "scripts": { "start": "better-npm-run dev", "build": "better-npm-run build" }, "betterScripts": { "dev": { "command": "webpack-dev-server", "env": { "NODE_ENV": "development" } }, "build": { "command": "webpack", "env": { "NODE_ENV": "production" } } }, ... } 


When the webpack settings are complicated, the webpack-merge plugin comes to the rescue - at the moment we will not use it, so as not to complicate the code.

Changes in webpack.config.js :

webpack.config.js
 const path = require('path'); const webpack = require('webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin'); //    const env = process.env.NODE_ENV; const __DEV__ = env === 'development'; const __PRODUCTION__ = env === 'production'; const paths = { src: path.resolve(__dirname, 'src'), dist: path.resolve(__dirname, 'dist') }; const config = { context: paths.src, entry: { app: './index' }, //     ,   output: { path: paths.dist, filename: __PRODUCTION__ ? '[name].bundle.[chunkhash].js' : '[name].bundle.js', chunkFilename: __PRODUCTION__ ? '[name].bundle.[chunkhash].js' : '[name].bundle.js' }, resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] }, module: { rules: [ { test: /\.tsx?$/, loader: 'awesome-typescript-loader' } ] }, plugins: [ //   NODE_ENV     new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(env) }), new HtmlWebpackPlugin({ template: './index.html' }), //      new webpack.optimize.ModuleConcatenationPlugin() ] }; if (__DEV__) { //  source map  development  config.devtool = 'inline-source-map'; } if (__PRODUCTION__) { config.plugins.push(new CleanWebpackPlugin(['dist'])); //   config.plugins.push(new webpack.optimize.UglifyJsPlugin()); } module.exports = config; 


Use the command for the build assembly:

 npm run build 

Upon completion of the build, we get a total bandwidth of about 180kb, about 55kb gzipped. Further, libraries from node_modules can be put into a separate bundle using CommonsChunkPlugin .

Topics for the following articles: routing, building progressive web application (PWA), server rendering, testing with Jest.

Thank you for attention!

Source: https://habr.com/ru/post/341132/


All Articles