Woongjae Lee
NHN Dooray - Frontend Team
2woongjae@gmail.com
~/Project/workshop-201801
➜ npx create-react-app redux-ts-quick-start --scripts-version=react-scripts-ts
~/Project/workshop-201801 took 1m 21s
➜ cd redux-ts-quick-start
Project/workshop-201801/redux-ts-quick-start is 📦 v0.1.0 via ⬢ v8.9.4
➜ npm i redux -D
+ redux@3.7.2
added 3 packages in 8.795s
Project/workshop-201801/redux-ts-quick-start is 📦 v0.1.0 via ⬢ v8.9.4 took 9s
➜ npm i react-redux @types/react-redux -D
+ react-redux@5.0.6
+ @types/react-redux@5.0.14
added 3 packages in 8.63s
Project/workshop-201801/redux-ts-quick-start is 📦 v0.1.0 via ⬢ v8.9.4 took 9s
➜ npm i react-router-dom @types/react-router-dom -D
+ react-router-dom@4.2.2
+ @types/react-router-dom@4.2.3
added 9 packages in 10.163s
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import reducer, { State } from './reducers';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
const store = createStore<State>(reducer);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root') as HTMLElement
);
registerServiceWorker();
import { combineReducers } from 'redux';
import { builds, Builds } from './builds';
export type State = Builds;
const reducer = combineReducers<State>({
builds
});
export default reducer;
import { BuildActions } from '../actions';
import * as types from '../constants';
export interface Build {
text: string;
completed: boolean;
id: number;
}
const initialState: Build[] = [];
export interface Builds {
builds: Build[];
}
export function builds(state: Build[] = initialState, action: BuildActions) {
switch (action.type) {
case types.ADD_BUILD:
return [
...state,
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
}
];
case types.DELETE_BUILD:
return state.filter(todo => todo.id !== action.id);
default:
return state;
}
}
import * as types from '../constants';
export interface AddBuild {
type: types.ADD_BUILD;
text: string;
}
export interface DeleteBuild {
type: types.DELETE_BUILD;
id: number;
}
export type BuildActions = AddBuild | DeleteBuild;
export const addBuild = (text: string): AddBuild => ({
type: types.ADD_BUILD,
text
});
export const deleteBuild = (id: number): DeleteBuild => ({
type: types.DELETE_BUILD,
id
});
export const ADD_BUILD = 'ADD_BUILD';
export type ADD_BUILD = typeof ADD_BUILD;
export const DELETE_BUILD = 'DELETE_BUILD';
export type DELETE_BUILD = typeof DELETE_BUILD;
import * as React from 'react';
import { BrowserRouter as Router, Route, Switch, Link } from 'react-router-dom';
import BuildContainer from './containers/BuildContainer';
const Routes = () => (
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/build">Build</Link>
</li>
<li>
<Link to="/version">Version</Link>
</li>
</ul>
);
const App = () => (
<Router>
<div>
<Routes />
<Switch>
<Route exact={true} path="/" render={() => <h2>Version</h2>} />
<Route path="/build" component={BuildContainer} />
<Route path="/version" render={() => <h2>Version</h2>} />
<Route render={() => <h2>404</h2>} />
</Switch>
</div>
</Router>
);
export default App;
import * as React from 'react';
import { bindActionCreators, AnyAction } from 'redux';
import { connect, Dispatch } from 'react-redux';
import * as BuildActions from '../actions';
import { Builds } from '../reducers/builds';
import { State } from '../reducers/index';
import Build from '../components/Build';
interface BuildProps {}
type BuildStateProps = Builds;
interface BuildDispatchProps {
actions: {
addBuild: (text: string) => AnyAction;
deleteBuild: (id: number) => AnyAction;
};
}
const Container: React.SFC<
BuildProps & BuildStateProps & BuildDispatchProps
> = ({ builds, actions }) => (
<Build
builds={builds}
addBuild={actions.addBuild}
deleteBuild={actions.deleteBuild}
/>
);
const mapStateToProps = (state: State): BuildStateProps => ({
builds: state.builds
});
const mapDispatchToProps = (
dispatch: Dispatch<AnyAction>
): BuildDispatchProps => ({
actions: bindActionCreators(BuildActions, dispatch)
});
const BuildContainer = connect(mapStateToProps, mapDispatchToProps)(Container);
export default BuildContainer;
import * as React from 'react';
import { AnyAction } from 'redux';
import { Build } from '../reducers/builds';
import BuildAdd from './BuildAdd';
import BuildCard from './BuildCard';
interface BuildProps {
builds: Build[];
addBuild: (text: string) => AnyAction;
deleteBuild: (id: number) => AnyAction;
}
const Build: React.SFC<BuildProps> = props => (
<div>
<BuildAdd addBuild={props.addBuild} />
<div>
{props.builds.map(build => (
<BuildCard
key={build.id}
id={build.id}
text={build.text}
delete={props.deleteBuild}
/>
))}
</div>
</div>
);
export default Build;
import * as React from 'react';
import { AnyAction } from 'redux';
interface BuildAddProps {
addBuild: (text: string) => AnyAction;
}
const BuildAdd: React.SFC<BuildAddProps> = props => {
let input: HTMLInputElement;
function addBuild() {
props.addBuild(input.value);
input.value = '';
}
return (
<div>
<input
type="text"
ref={ref => {
input = ref as HTMLInputElement;
}}
/>
<button onClick={addBuild}>추가</button>
</div>
);
};
export default BuildAdd;
import * as React from 'react';
interface BuildCardProps {
id: number;
text: string;
delete: Function;
}
const BuildCard: React.SFC<BuildCardProps> = props => (
<div>
<h2>build</h2>
<p>{props.text}</p>
<button onClick={() => props.delete(props.id)}>delete</button>
</div>
);
export default BuildCard;
// 파일 분리를 하지 않을때
// ADD_TODO => addTodo
// 액션의 type 정의
const ADD_TODO = 'ADD_TODO';
// 액션 생산자
// 액션의 타입은 미리 정의한 타입으로 부터 가져와서 사용하며,
// 사용자가 인자로 주지 않습니다.
export function addTodo(text) {
return { type: ADD_TODO, text }; // { type: ADD_TODO, text: text }
}
function todoApp(state, action) { return state; }
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, {
text: action.text,
completed: false
}];
case COMPLETE_TODO:
return [
...state.slice(0, action.index),
Object.assign({}, state[action.index], {
completed: true
}),
...state.slice(action.index + 1)
];
default:
return state;
}
}
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
});
case ADD_TODO:
return Object.assign({}, state, {
todos: [...state.todos, {
text: action.text,
completed: false
}]
});
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: [
...state.todos.slice(0, action.index),
Object.assign({}, state.todos[action.index], {
completed: true
}),
...state.todos.slice(action.index + 1)
]
});
default:
return state;
}
}
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, {
text: action.text,
completed: false
}];
case COMPLETE_TODO:
return [
...state.slice(0, action.index),
Object.assign({}, state[action.index], {
completed: true
}),
...state.slice(action.index + 1)
];
default:
return state;
}
}
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter;
default:
return state;
}
}
function todoApp(state = {}, action) {
return {
visibilityFilter: visibilityFilter(state.visibilityFilter, action),
todos: todos(state.todos, action)
};
}
import { combineReducers } from 'redux';
const todoApp = combineReducers({
visibilityFilter,
todos
});
getState(): S;
dispatch: Dispatch<S>;
subscribe(listener: () => void): Unsubscribe;
리턴이 Unsubscribe 라는 점 !
replaceReducer(nextReducer: Reducer<S>): void;
// npm i redux -D
import {createStore} from 'redux';
// 타입 정의
const ADD_AGE = 'ADD_AGE';
// 타입 생성 함수
function addAge(): {type: string;} {
return {
type: ADD_AGE
};
}
// 리듀서
function ageApp(state: {age: number;} = {age: 35}, action: {type: string;}): {age: number} {
if (action.type === ADD_AGE) {
return {age: state.age + 1};
}
return state;
}
// 스토어 만들기
const store = createStore<{age: number;}>(ageApp);
// index.tsx 에서 랜더링 다시 하기
function render() {
ReactDOM.render(
<App store={store} />,
document.getElementById('root') as HTMLElement
);
}
store.subscribe(render);
render();
// App.tsx 에서 랜더링 다시 하기
ReactDOM.render(
<App store={store} />,
document.getElementById('root') as HTMLElement
);
import {Store, Unsubscribe} from 'redux';
import {addAge} from './index';
interface AppProps {
store: Store<{ age: number; }>;
}
class App extends React.Component<AppProps, {}> {
private _unsubscribe: Unsubscribe;
constructor(props: AppProps) {
super(props);
this._addAge = this._addAge.bind(this);
}
componentDidMount() {
const store = this.props.store;
this._unsubscribe = store.subscribe(() => {
this.forceUpdate();
});
}
componentWillUnmount() {
if (this._unsubscribe !== null) {
this._unsubscribe();
}
}
render() {
const state = this.props.store.getState();
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
나이가 {state.age}
<button onClick={this._addAge}>한해가 지났다.</button>
</p>
</div>
);
}
private _addAge(): void {
const store = this.props.store;
const action = addAge();
store.dispatch(action);
}
}
import * as PropTypes from 'prop-types';
// Provider 만들기
class Provider extends React.Component<{ store: Store<{ age: number; }>; children: JSX.Element; }, {}> {
public static childContextTypes = {
store: PropTypes.object // React.PropTypes.object
};
getChildContext() {
return {
store: this.props.store
};
}
render() {
return this.props.children;
}
}
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
import * as PropTypes from 'prop-types';
class App extends React.Component<{}, {}> {
// App.contextTypes 에 스토어를 받아오도록 정의해야 합니다.
public static contextTypes = {
store: PropTypes.object
};
private _unsubscribe: Unsubscribe;
constructor(props: {}) {
super(props);
this._addAge = this._addAge.bind(this);
}
componentDidMount() {
const store = this.context.store;
this._unsubscribe = store.subscribe(() => {
this.forceUpdate();
});
}
componentWillUnmount() {
if (this._unsubscribe !== null) {
this._unsubscribe();
}
}
render() {
const state = this.context.store.getState();
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
나이가 {state.age}
<button onClick={this._addAge}>한해가 지났다.</button>
</p>
</div>
);
}
private _addAge(): void {
const store = this.context.store;
const action = addAge();
store.dispatch(action);
}
}
import * as React from 'react';
import {Unsubscribe} from 'redux';
import {addAge} from './index';
import * as PropTypes from 'prop-types';
class Button extends React.Component<{}, {}> {
// Button.contextTypes 에 스토어를 받아오도록 정의해야 합니다.
public static contextTypes = {
store: PropTypes.object
};
private _unsubscribe: Unsubscribe;
constructor(props: {}) {
super(props);
this._addAge = this._addAge.bind(this);
}
componentDidMount() {
const store = this.context.store;
this._unsubscribe = store.subscribe(() => {
this.forceUpdate();
});
}
componentWillUnmount() {
if (this._unsubscribe !== null) {
this._unsubscribe();
}
}
render() {
return <button onClick={this._addAge}>하위 컴포넌트에서 한해가 지났다.</button>;
}
private _addAge(): void {
const store = this.context.store;
const action = addAge();
store.dispatch(action);
}
}
export default Button;
// npm i redux -D
import {createStore} from 'redux';
// npm i react-redux @types/react-redux -D
import {Provider} from 'react-redux';
const store = createStore<{ age: number; }>(ageApp);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
// 나머지 코드 그대로 두고도 작동합니다
import * as ReactRedux from 'react-redux';
const { connect } = ReactRedux;
const AppContainer = connect(
mapStateToProps,
mapDispatchToProps
)(App);
// 1. mapStateToProps - 어떤 state 를 어떤 props 에 연결할 것인지에 대한 정의
// 2. mapDispatchToProps - 어떤 dispatch(action) 을 어떤 props 에 연결할 것인지에 대한 정의
// 3. App - 그 props 를 보낼 컴포넌트를 정의
export default AppContainer;
// 이 함수는 store.getState() 한 state 를
// 연결한(connect) App 컴포넌트의 어떤 props 로 줄 것인지를 리턴
// 그래서 이 함수의 리턴이 곧 App 컴포넌트의 AppProps 의 부분집합이어야 한다.
const mapStateToProps = (state: { age: number; }) => {
return {
age: state.age,
};
};
// 이 함수는 store.dispatch(액션)을
// 연결한(connect) App 컴포넌트의 어떤 props 로 줄 것인지를 리턴
// 그래서 이 함수의 리턴이 곧 App 컴포넌트의 AppProps 의 부분집합이어야 한다.
const mapDispatchToProps = (dispatch: Function) => {
return {
onAddClick: () => {
dispatch(addAge());
}
};
};
// mapStateToProps 와 mapDispatchToProps 의 리턴을 합치면 나오는 형태로 지정
interface AppProps {
age: number;
onAddClick(): void;
}
/*
class App extends React.Component<AppProps, {}> {
render() {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
나이가 {this.props.age}
<button onClick={this.props.onAddClick}>한해가 지났다.</button>
<Button />
</p>
</div>
);
}
}
*/
const App: React.SFC<AppProps> = (props) => {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
나이가 {props.age}
<button onClick={props.onAddClick}>한해가 지났다.</button>
<Button />
</p>
</div>
);
};
app.get('*', (req, res) => {
const html = path.join(__dirname, '../build/index.html');
const htmlData = fs.readFileSync(html).toString();
const store = createStore(ageApp);
const ReactApp = ReactDOMServer.renderToString(
<Provider store={store}>
<AppContainer />
</Provider>
);
const initialState = store.getState();
const renderedHtml = htmlData.replace(`<div id="root">{{SSR}}</div>`, `<div id="root">${ReactApp}</div><script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};</script>`);
res.status(200).send(renderedHtml);
});
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root">${html}</div>
<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};</script>
</body>
const initialState = (window as any).__INITIAL_STATE__;
// 서버에서 받은 초기값으로 스토어 만들기
const store = createStore<{ age: number; }>(ageApp, initialState);
React.render(
<Provider store={store}>
<AppContainer />
</Provider>,
document.getElementById('root')
);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/favicon.ico">
<title>React App</title>
<link href="/static/css/main.cacbacc7.css" rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<div class="App" data-reactroot="" data-reactid="1" data-react-checksum="1528495119">
<div class="App-header" data-reactid="2">
<img src="/logo.svg" class="App-logo" alt="logo" data-reactid="3"/>
<h2 data-reactid="4">Welcome to React</h2>
</div>
<p class="App-intro" data-reactid="5">
<!-- react-text: 6 -->나이가
<!-- /react-text -->
<!-- react-text: 7 -->35
<!-- /react-text -->
<button data-reactid="8">한해가 지났다.</button>
<button data-reactid="9">하위 컴포넌트에서 한해가 지났다.</button>
</p>
</div>
</div>
<script>window.__INITIAL_STATE__ = {"age":35};</script>
<script type="text/javascript" src="/static/js/main.ccc68f1a.js"></script>
</body>
</html>
// 타입 정의
export const START_GITHUB_API = 'START_GITHUB_API';
export const ERROR_GITHUB_API = 'ERROR_GITHUB_API';
export const END_GITHUB_API = 'END_GITHUB_API';
// 타입 생성 함수
export function startGithubApi(): { type: string; } {
return {
type: ADD_AGE
};
}
export function errorGithubApi(): { type: string; } {
return {
type: ADD_AGE
};
}
export function endGithubApi(age: number): { type: string; age: number; } {
return {
type: ADD_AGE,
age
};
}
// @types/react-redux
// connect 함수를 mapStateToProps 함수 하나만 인자로 쓰면 DispatchProp 가 넘어옵니다.
export declare function connect<TStateProps, no_dispatch, TOwnProps>(
mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps>
): ComponentDecorator<DispatchProp<any> & TStateProps, TOwnProps>;
// 수정
const { connect } = ReactRedux;
const mapStateToProps = (state: { age: number; }) => {
return {
age: state.age
};
};
const AppContainer = connect(mapStateToProps)(App);
// @types/react-redux
type Dispatch<S> = Redux.Dispatch<S>;
// @types/react-redux
export interface DispatchProp<S> {
dispatch: Dispatch<S>;
}
// redux/index.d.ts
export interface Dispatch<S> {
<A extends Action>(action: A): A;
}
const App: React.SFC<AppProps & ReactRedux.DispatchProp<{}>> = (props) => {
function getCountFromGithub(): void {
// 여기를 구현
}
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
나이가 {props.age}
<button onClick={() => props.dispatch(addAge())}>한해가 지났다.</button>
<button onClick={() => getCountFromGithub()}>깃헙 API 비동기 호출</button>
</p>
</div>
);
};
function getCountFromGithub(): void {
const dispatch: ReactRedux.Dispatch<any> = props.dispatch;
dispatch(startGithubApi());
request.get('https://api.github.com/users')
.end((err, res) => {
if (err) {
return dispatch(errorGithubApi());
}
const age = JSON.parse(res.text).length;
return dispatch(endGithubApi(age));
});
}
async function getCountFromGithub(): Promise<void> {
const dispatch: ReactRedux.Dispatch<{}> = props.dispatch;
dispatch(startGithubApi());
let res = null;
try {
res = await request.get('https://api.github.com/users');
} catch (e) {
dispatch(errorGithubApi());
return;
}
const age = JSON.parse(res.text).length;
dispatch(endGithubApi(age));
return;
}
import {ADD_AGE, START_GITHUB_API, ERROR_GITHUB_API, END_GITHUB_API} from '../action';
export function ageApp(state: { age: number; } = {age: 35}, action: { type: string; age: number; }): { age: number; } {
if (action.type === ADD_AGE) {
return {age: state.age + 1};
} else if (action.type === START_GITHUB_API) {
return {age: 0};
} else if (action.type === ERROR_GITHUB_API) {
return {age: 35};
} else if (action.type === END_GITHUB_API) {
return {age: action.age};
}
return state;
}
function middleware(store) {
return (next: any) => (action: any) => {
// 다음 미들웨어 호출, 없으면 dispatch
const returnValue = next(action);
return returnValue;
};
}
import {middlewareA, middlewareB} from './Middleware';
// 스토어를 만들때 순서대로 넣어주면, 순서대로 미들웨어 실행
const store = createStore<{ age: number; }>(ageApp, applyMiddleware(middlewareA, middlewareB));
function middleware(store: Store<{ age: number; }>) {
return (next: any) => (action: any) => {
console.log(`before store : ${JSON.stringify(store.getState())}`); // before
const returnValue = next(action); // 다음 미들웨어 호출, 없으면 실제 dispatch
console.log(`after : ${JSON.stringify(store.getState())}`); // after
return returnValue;
};
}
const store = createStore<{ age: number; }>(ageApp, applyMiddleware(middleware));
// before store : {"age":35}
// after : {"age":36}
export function middlewareA(store: Store<{ age: number; }>) {
return (next: any) => (action: any) => {
console.log(`A before store : ${JSON.stringify(store.getState())}`); // before
const returnValue = next(action); // 다음 미들웨어 호출, 없으면 실제 dispatch
console.log(`A after : ${JSON.stringify(store.getState())}`); // after
return returnValue;
};
}
export function middlewareB(store: Store<{ age: number; }>) {
return (next: any) => (action: any) => {
console.log(`B before store : ${JSON.stringify(store.getState())}`); // before
const returnValue = next(action); // 다음 미들웨어 호출, 없으면 실제 dispatch
console.log(`B after : ${JSON.stringify(store.getState())}`); // after
return returnValue;
};
}
const store = createStore<{ age: number; }>(ageApp, applyMiddleware(middlewareA, middlewareB));
// A before store : {"age":35}
// B before store : {"age":35}
// B after : {"age":36}
// A after : {"age":36}
// import
import thunk from 'redux-thunk';
// 미들웨어 설정
const store = createStore<{ age: number; }>(ageApp, applyMiddleware(middleware, thunk));
export function addAge(): { type: string; } {
return {
type: ADD_AGE
};
}
export function addAgeAsync() {
return (dispatch: any) => {
setTimeout(() => {
dispatch(addAge());
}, 1000);
};
}
// 사용
const App: React.SFC<AppProps & ReactRedux.DispatchProp<{}>> = (props) => {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
나이가 {props.age}
<button onClick={() => props.dispatch(addAgeAsync())}>한해가 지났다.</button>
</p>
</div>
);
};
By Woongjae Lee
코드버스킹 워크샵 - React with TypeScript 세번째 (2018년 1월 버전)