├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── actions │ ├── ActionTypes.js │ └── index.js ├── components │ ├── Buttons.css │ ├── Buttons.js │ ├── Counter.css │ ├── Counter.js │ ├── CounterList.css │ └── CounterList.js ├── containers │ ├── App.js │ └── CounterListContainer.js ├── index.css ├── index.js ├── reducers │ └── index.js ├── registerServiceWorker.js └── utils │ └── index.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-counter 2 | 3 | 리덕스 기초부터 ImmutableJS, ducks 구조와 redux-actions 를 통한 손쉬운 액션관리까지 다루는 프로젝트 4 | 5 | [튜토리얼](https://velopert.com/3365) 6 | 7 | 각 섹션별로 branch / tag 가 생성되어있습니다. 8 | 9 | ## branch 10 | 11 | - basic-counter: 리덕스 기초를 배우면서 만든 단일 카운터 12 | - multi-counter: 조금 더 복잡해진 상태를 다루는 멀티 카운터 13 | - immutable: ImmutableJS 를 적용하여 더욱 쉬워진 상태관리 14 | - ducks: Ducks 구조와 redux-actions 를 통한 쉬워진 액션관리 15 | 16 | ## tag 17 | 18 | 태그는 각 챕터당 모든 섹션마다 만들어져있습니다. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-counter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "immutable": "^3.8.1", 7 | "react": "^15.5.4", 8 | "react-dom": "^15.5.4", 9 | "react-redux": "^5.0.5", 10 | "redux": "^3.6.0" 11 | }, 12 | "devDependencies": { 13 | "react-scripts": "1.0.7" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test --env=jsdom", 19 | "eject": "react-scripts eject" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlpt-playground/redux-counter/cac1f88e43deb4546762bb1d2d6ac551b368cf38/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/actions/ActionTypes.js: -------------------------------------------------------------------------------- 1 | /* 2 | Action 의 종류들을 선언합니다. 3 | 앞에 export 를 붙임으로서, 나중에 이것들을 불러올 때, 4 | import * as types from './ActionTypes' 를 할 수 있어요. 5 | */ 6 | 7 | export const CREATE = 'CREATE'; 8 | export const REMOVE = 'REMOVE'; 9 | 10 | export const INCREMENT = 'INCREMENT'; 11 | export const DECREMENT = 'DECREMENT'; 12 | export const SET_COLOR = 'SET_COLOR'; -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | action 객체를 만드는 액션 생성자들을 선언합니다. (action creators) 3 | 여기서 () => ({}) 은, function() { return { } } 와 동일한 의미입니다. 4 | scope 이슈와 관계 없이 편의상 사용되었습니다. 5 | */ 6 | 7 | import * as types from './ActionTypes'; 8 | 9 | export const create = (color) => ({ 10 | type: types.CREATE, 11 | color 12 | }); 13 | 14 | export const remove = () => ({ 15 | type: types.REMOVE 16 | }); 17 | 18 | export const increment = (index) => ({ 19 | type: types.INCREMENT, 20 | index 21 | }); 22 | 23 | export const decrement = (index) => ({ 24 | type: types.DECREMENT, 25 | index 26 | }); 27 | 28 | export const setColor = ({index, color}) => ({ 29 | type: types.SET_COLOR, 30 | index, 31 | color 32 | }); -------------------------------------------------------------------------------- /src/components/Buttons.css: -------------------------------------------------------------------------------- 1 | .Buttons { 2 | display: flex; 3 | } 4 | 5 | .Buttons .btn { 6 | flex: 1; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | height: 3rem; 11 | 12 | color: white; 13 | font-size: 1.5rem; 14 | cursor: pointer; 15 | } 16 | 17 | .Buttons .add { 18 | background: #37b24d; 19 | } 20 | 21 | .Buttons .add:hover { 22 | background: #40c057; 23 | } 24 | 25 | .Buttons .remove { 26 | background: #f03e3e; 27 | } 28 | 29 | .Buttons .remove:hover { 30 | background: #fa5252; 31 | } -------------------------------------------------------------------------------- /src/components/Buttons.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './Buttons.css'; 5 | 6 | const Buttons = ({onCreate, onRemove}) => { 7 | return ( 8 |
9 |
10 | 생성 11 |
12 |
13 | 제거 14 |
15 |
16 | ); 17 | }; 18 | 19 | Buttons.propTypes = { 20 | onCreate: PropTypes.func, 21 | onRemove: PropTypes.func 22 | }; 23 | 24 | Buttons.defaultProps = { 25 | onCreate: () => console.warn('onCreate not defined'), 26 | onRemove: () => console.warn('onRemove not defined') 27 | }; 28 | 29 | export default Buttons; -------------------------------------------------------------------------------- /src/components/Counter.css: -------------------------------------------------------------------------------- 1 | .Counter { 2 | /* 레이아웃 */ 3 | width: 10rem; 4 | height: 10rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | margin: 1rem; 9 | 10 | /* 색상 */ 11 | color: white; 12 | 13 | /* 폰트 */ 14 | font-size: 3rem; 15 | 16 | /* 기타 */ 17 | border-radius: 100%; 18 | cursor: pointer; 19 | user-select: none; 20 | transition: background-color 0.75s; 21 | } -------------------------------------------------------------------------------- /src/components/Counter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Counter.css'; 4 | 5 | const Counter = ({number, color, index, onIncrement, onDecrement, onSetColor}) => { 6 | return ( 7 |
onIncrement(index)} 10 | onContextMenu={ 11 | (e) => { 12 | e.preventDefault(); 13 | onDecrement(index); 14 | } 15 | } 16 | onDoubleClick={() => onSetColor(index)} 17 | style={{backgroundColor: color}}> 18 | {number} 19 |
20 | ); 21 | }; 22 | 23 | Counter.propTypes = { 24 | index: PropTypes.number, 25 | number: PropTypes.number, 26 | color: PropTypes.string, 27 | onIncrement: PropTypes.func, 28 | onDecrement: PropTypes.func, 29 | onSetColor: PropTypes.func 30 | }; 31 | 32 | Counter.defaultProps = { 33 | index: 0, 34 | number: 0, 35 | color: 'black', 36 | onIncrement: () => console.warn('onIncrement not defined'), 37 | onDecrement: () => console.warn('onDecrement not defined'), 38 | onSetColor: () => console.warn('onSetColor not defined') 39 | }; 40 | 41 | export default Counter; -------------------------------------------------------------------------------- /src/components/CounterList.css: -------------------------------------------------------------------------------- 1 | .CounterList { 2 | margin-top: 2rem; 3 | display: flex; 4 | justify-content: center; 5 | flex-wrap: wrap; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/CounterList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Counter from './Counter'; 3 | import PropTypes from 'prop-types'; 4 | import { List } from 'immutable'; 5 | 6 | import './CounterList.css'; 7 | 8 | const CounterList = ({counters, onIncrement, onDecrement, onSetColor}) => { 9 | 10 | const counterList = counters.map( 11 | (counter, i) => ( 12 | 20 | ) 21 | ); 22 | 23 | return ( 24 |
25 | {counterList} 26 |
27 | ); 28 | }; 29 | 30 | CounterList.propTypes = { 31 | counters: PropTypes.instanceOf(List), 32 | onIncrement: PropTypes.func, 33 | onDecrement: PropTypes.func, 34 | onSetColor: PropTypes.func 35 | }; 36 | 37 | CounterList.defaultProps = { 38 | counters: [], 39 | onIncrement: () => console.warn('onIncrement not defined'), 40 | onDecrement: () => console.warn('onDecrement not defined'), 41 | onSetColor: () => console.warn('onSetColor not defined') 42 | } 43 | 44 | export default CounterList; -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import Buttons from '../components/Buttons'; 3 | import CounterListContainer from './CounterListContainer'; 4 | 5 | import { connect } from 'react-redux'; 6 | import * as actions from '../actions'; 7 | 8 | import { getRandomColor } from '../utils'; 9 | 10 | class App extends Component { 11 | render() { 12 | const { onCreate, onRemove } = this.props; 13 | return ( 14 |
15 | 19 | 20 |
21 | ); 22 | } 23 | } 24 | 25 | // 액션함수 준비 26 | const mapToDispatch = (dispatch) => ({ 27 | onCreate: () => dispatch(actions.create(getRandomColor())), 28 | onRemove: () => dispatch(actions.remove()) 29 | }); 30 | 31 | // 리덕스에 연결을 시키고 내보낸다 32 | export default connect(null, mapToDispatch)(App); -------------------------------------------------------------------------------- /src/containers/CounterListContainer.js: -------------------------------------------------------------------------------- 1 | import CounterList from '../components/CounterList'; 2 | import * as actions from '../actions'; 3 | import { connect } from 'react-redux'; 4 | import { getRandomColor } from '../utils'; 5 | 6 | // store 안의 state 값을 props 로 연결해줍니다. 7 | const mapStateToProps = (state) => ({ 8 | counters: state.get('counters') 9 | }); 10 | 11 | /* 12 | 액션 생성자를 사용하여 액션을 생성하고, 13 | 해당 액션을 dispatch 하는 함수를 만들은 후, 이를 props 로 연결해줍니다. 14 | */ 15 | 16 | const mapDispatchToProps = (dispatch) => ({ 17 | onIncrement: (index) => dispatch(actions.increment(index)), 18 | onDecrement: (index) => dispatch(actions.decrement(index)), 19 | onSetColor: (index) => { 20 | const color = getRandomColor(); 21 | dispatch(actions.setColor({ index, color})); 22 | } 23 | }) 24 | 25 | // 데이터와 함수들이 props 로 붙은 컴포넌트 생성 26 | const CounterListContainer = connect( 27 | mapStateToProps, 28 | mapDispatchToProps 29 | )(CounterList); 30 | 31 | export default CounterListContainer; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './containers/App'; 4 | import './index.css'; 5 | 6 | // Redux 관련 불러오기 7 | import { createStore } from 'redux' 8 | import reducers from './reducers'; 9 | import { Provider } from 'react-redux'; 10 | 11 | // 스토어 생성 12 | const store = createStore(reducers, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()); 13 | 14 | ReactDOM.render( 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ); -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../actions/ActionTypes'; 2 | import { Map, List } from 'immutable'; 3 | 4 | // 초기 상태를 정의합니다. 5 | const initialState = Map({ 6 | counters: List([ 7 | Map({ 8 | color: 'black', 9 | number: 0 10 | }) 11 | ]) 12 | }) 13 | 14 | // 리듀서 함수를 정의합니다. 15 | function counter(state = initialState, action) { 16 | const counters = state.get('counters'); 17 | 18 | switch(action.type) { 19 | // 카운터를 새로 추가합니다 20 | case types.CREATE: 21 | return state.set('counters', counters.push(Map({ 22 | color: action.color, 23 | number: 0 24 | }))) 25 | // slice 를 이용하여 맨 마지막 카운터를 제외시킵니다 26 | case types.REMOVE: 27 | return state.set('counters', counters.pop()); 28 | 29 | // action.index 번째 카운터의 number 에 1 을 더합니다. 30 | case types.INCREMENT: 31 | return state.set('counters', counters.update( 32 | action.index, 33 | (counter) => counter.set('number', counter.get('number') + 1)) 34 | ); 35 | 36 | // action.index 번째 카운터의 number 에 1 을 뺍니다 37 | case types.DECREMENT: 38 | return state.set('counters', counters.update( 39 | action.index, 40 | (counter) => counter.set('number', counter.get('number') - 1)) 41 | ); 42 | 43 | // action.index 번째 카운터의 색상을 변경합니다 44 | case types.SET_COLOR: 45 | return state.set('counters', counters.update( 46 | action.index, 47 | (counter) => counter.set('color', action.color)) 48 | ); 49 | default: 50 | return state; 51 | } 52 | }; 53 | 54 | export default counter; -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | export default function register() { 12 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 13 | window.addEventListener('load', () => { 14 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 15 | navigator.serviceWorker 16 | .register(swUrl) 17 | .then(registration => { 18 | registration.onupdatefound = () => { 19 | const installingWorker = registration.installing; 20 | installingWorker.onstatechange = () => { 21 | if (installingWorker.state === 'installed') { 22 | if (navigator.serviceWorker.controller) { 23 | // At this point, the old content will have been purged and 24 | // the fresh content will have been added to the cache. 25 | // It's the perfect time to display a "New content is 26 | // available; please refresh." message in your web app. 27 | console.log('New content is available; please refresh.'); 28 | } else { 29 | // At this point, everything has been precached. 30 | // It's the perfect time to display a 31 | // "Content is cached for offline use." message. 32 | console.log('Content is cached for offline use.'); 33 | } 34 | } 35 | }; 36 | }; 37 | }) 38 | .catch(error => { 39 | console.error('Error during service worker registration:', error); 40 | }); 41 | }); 42 | } 43 | } 44 | 45 | export function unregister() { 46 | if ('serviceWorker' in navigator) { 47 | navigator.serviceWorker.ready.then(registration => { 48 | registration.unregister(); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export function getRandomColor() { 2 | const colors = [ 3 | '#495057', 4 | '#f03e3e', 5 | '#d6336c', 6 | '#ae3ec9', 7 | '#7048e8', 8 | '#4263eb', 9 | '#1c7cd6', 10 | '#1098ad', 11 | '#0ca678', 12 | '#37b24d', 13 | '#74b816', 14 | '#f59f00', 15 | '#f76707' 16 | ]; 17 | 18 | // 0 부터 12까지 랜덤 숫자 19 | const random = Math.floor(Math.random() * 13); 20 | 21 | // 랜덤 색상 반환 22 | return colors[random]; 23 | } --------------------------------------------------------------------------------