├── .gitignore ├── README.md ├── img └── right_arrow.png ├── package.json ├── public ├── favicon.ico └── index.html └── src ├── App.css ├── App.js ├── components ├── ListRow.js ├── ListView.js ├── PostView.js └── TopicFilter.js ├── containers ├── PostsScreen.css ├── PostsScreen.js ├── TopicsScreen.css └── TopicsScreen.js ├── index.css ├── index.js ├── services └── reddit.js └── store ├── posts ├── actionTypes.js ├── actions.js └── reducer.js ├── reducers.js └── topics ├── actionTypes.js ├── actions.js └── reducer.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://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 15 | npm-debug.log 16 | .idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Dataflow Example 2 | 3 | A real-life example of a React project with focus on dataflow management. The example explores and compares methodologies by implementing the same app in different branches: 4 | 5 | * [redux-thunk branch](https://github.com/wix/react-dataflow-example/tree/redux-thunk/src) - Basic Redux (thunks)
Walkthrough post: [Redux Step by Step: A Simple and Robust Workflow for Real Life Apps](https://medium.com/@talkol/redux-step-by-step-a-simple-and-robust-workflow-for-real-life-apps-1fdf7df46092#.bgcu9iz3i) 6 | 7 | * [redux-thunk-tests branch](https://github.com/wix/react-dataflow-example/tree/redux-thunk-tests/src) - Tests for basic Redux (thunks)
Walkthrough post: [Redux Testing Step by Step: A Simple Methodology for Testing Business Logic](https://medium.com/@talkol/redux-testing-step-by-step-a-simple-methodology-for-testing-business-logic-8901670756ce) 8 | 9 | ## Running locally 10 | 11 | * `npm install` 12 | * `npm start` 13 | * Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 14 | 15 | ## Want to contribute your own flavor? 16 | 17 | 1. Implement the entire app and reach the same end result using your own methodology 18 | 2. Recommended: write a blog post explaining your methodology 19 | 3. Submit a pull request and we'll add it as an additional branch 20 | 21 | ## What does the example app do? 22 | 23 | On the first screen, it asks the user for 3 topics they’re interested in. We pull the list of topics from [Reddit](https://www.reddit.com/)’s list of default front page subreddits. After the user makes a choice, it shows the list of posts from each of these 3 topics in a filterable list — all topics or just one of the 3. When the user clicks on a post in the list, the post content is shown. 24 | 25 | ## License 26 | 27 | MIT 28 | -------------------------------------------------------------------------------- /img/right_arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wix-incubator/react-dataflow-example/993697aadfac7215fe310113a8e92d62215dde02/img/right_arrow.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dataflow-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "0.9.5" 7 | }, 8 | "dependencies": { 9 | "lodash": "^4.16.4", 10 | "react": "^15.3.2", 11 | "react-autobind": "^1.0.6", 12 | "react-dom": "^15.3.2", 13 | "react-redux": "^4.4.5", 14 | "redux": "^3.6.0", 15 | "redux-thunk": "^2.1.0", 16 | "seamless-immutable": "^6.1.4" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test --env=jsdom", 22 | "eject": "react-scripts eject" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wix-incubator/react-dataflow-example/993697aadfac7215fe310113a8e92d62215dde02/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React Dataflow Example 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as topicsSelectors from './store/topics/reducer'; 4 | import TopicsScreen from './containers/TopicsScreen'; 5 | import PostsScreen from './containers/PostsScreen'; 6 | import './App.css'; 7 | 8 | class App extends Component { 9 | render() { 10 | return ( 11 |
12 | {!this.props.isSelectionFinalized ? 13 | : 14 | 15 | } 16 |
17 | ); 18 | } 19 | } 20 | 21 | // which props do we want to inject, given the global store state? 22 | function mapStateToProps(state) { 23 | return { 24 | isSelectionFinalized: topicsSelectors.isTopicSelectionFinalized(state) 25 | }; 26 | } 27 | 28 | export default connect(mapStateToProps)(App); 29 | -------------------------------------------------------------------------------- /src/components/ListRow.js: -------------------------------------------------------------------------------- 1 | // components are "dumb" react components that are not aware of redux 2 | // they receive data from their parents through regular react props 3 | // they are allowed to have local component state and view logic 4 | // use them to avoid having view logic & local component state in "smart" components 5 | 6 | import React, { Component } from 'react'; 7 | import autoBind from 'react-autobind'; 8 | 9 | export default class ListRow extends Component { 10 | 11 | constructor(props) { 12 | super(props); 13 | autoBind(this); 14 | } 15 | 16 | render() { 17 | const backgroundColor = this.props.selected ? '#c0f0ff' : '#fff'; 18 | return ( 19 |
22 | {this.props.children} 23 |
24 | ); 25 | } 26 | 27 | onClick() { 28 | if (typeof this.props.onClick === 'function') { 29 | this.props.onClick(this.props.rowId); 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ListView.js: -------------------------------------------------------------------------------- 1 | // components are "dumb" react components that are not aware of redux 2 | // they receive data from their parents through regular react props 3 | // they are allowed to have local component state and view logic 4 | // use them to avoid having view logic & local component state in "smart" components 5 | 6 | import _ from 'lodash'; 7 | import React, { Component } from 'react'; 8 | import autoBind from 'react-autobind'; 9 | 10 | export default class ListView extends Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | autoBind(this); 15 | } 16 | 17 | render() { 18 | return ( 19 | 22 | ); 23 | } 24 | 25 | renderRowById(rowId) { 26 | return ( 27 |
  • 28 | {this.renderRowThroughProps(rowId)} 29 |
  • 30 | ); 31 | } 32 | 33 | renderRowThroughProps(rowId) { 34 | if (typeof this.props.renderRow === 'function') { 35 | return this.props.renderRow(rowId, _.get(this.props.rowsById, rowId)); 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/components/PostView.js: -------------------------------------------------------------------------------- 1 | // components are "dumb" react components that are not aware of redux 2 | // they receive data from their parents through regular react props 3 | // they are allowed to have local component state and view logic 4 | // use them to avoid having view logic & local component state in "smart" components 5 | 6 | import React, { Component } from 'react'; 7 | 8 | export default class PostView extends Component { 9 | 10 | render() { 11 | if (!this.props.post) return this.renderEmpty(); 12 | if (this.props.post.body) return this.renderBody(); 13 | else if (this._isImage(this.props.post.url)) return this.renderImage(); 14 | else return this.renderUrl(); 15 | } 16 | 17 | renderEmpty() { 18 | return ( 19 |
    20 |

    Select a post to view

    21 |
    22 | ); 23 | } 24 | 25 | renderBody() { 26 | return ( 27 |
    28 | {this.props.post.body} 29 |
    30 | ); 31 | } 32 | 33 | renderImage() { 34 | return ( 35 | {this.props.post.title} 36 | ); 37 | } 38 | 39 | renderUrl() { 40 | return ( 41 |
    42 |

    External Link

    43 | Open 44 |
    45 | ); 46 | } 47 | 48 | _isImage(url) { 49 | if (!url) return false; 50 | return (url.endsWith('.jpg') || url.endsWith('.gif') || url.endsWith('.png')); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/components/TopicFilter.js: -------------------------------------------------------------------------------- 1 | // components are "dumb" react components that are not aware of redux 2 | // they receive data from their parents through regular react props 3 | // they are allowed to have local component state and view logic 4 | // use them to avoid having view logic & local component state in "smart" components 5 | 6 | import _ from 'lodash'; 7 | import React, { Component } from 'react'; 8 | 9 | export default class TopicFilter extends Component { 10 | 11 | render() { 12 | return ( 13 |
    14 | {this.renderFilter('all', 'All')} 15 | {_.map(this.props.topics, (topic, topicId) => this.renderFilter(topicId, topic.title))} 16 |
    17 | ); 18 | } 19 | 20 | renderFilter(id, label) { 21 | const className = this.props.selected === id ? 'selected' : undefined; 22 | return ( 23 | this.onFilterClick(id)}> 28 | {label} 29 | 30 | ); 31 | } 32 | 33 | onFilterClick(id) { 34 | if (id === this.props.selected) return; 35 | if (typeof this.props.onChanged === 'function') { 36 | this.props.onChanged(id); 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/containers/PostsScreen.css: -------------------------------------------------------------------------------- 1 | .PostsScreen { 2 | padding: 20px; 3 | display: flex; 4 | } 5 | 6 | .PostsScreen .LeftPane { 7 | flex: 3; 8 | } 9 | 10 | .PostsScreen .ContentPane { 11 | flex: 7; 12 | padding: 10px; 13 | margin-left: 20px; 14 | overflow-y: auto; 15 | max-height: calc(100vh - 65px); 16 | align-self: center; 17 | } 18 | 19 | .PostsScreen .ContentPane img { 20 | max-width: 100%; 21 | max-height: calc(100vh - 80px); 22 | } 23 | 24 | .PostsScreen ul { 25 | list-style: none; 26 | padding-left: 0; 27 | margin: auto; 28 | border: 1px solid #aaa; 29 | max-height: calc(100vh - 90px); 30 | overflow-x: hidden; 31 | overflow-y: scroll; 32 | } 33 | 34 | .PostsScreen li { 35 | text-align: left; 36 | border-bottom: 1px solid #aaa; 37 | cursor: pointer; 38 | } 39 | 40 | .PostsScreen li > div { 41 | padding-left: 20px; 42 | display: flex; 43 | align-items: center; 44 | min-height: 60px; 45 | } 46 | 47 | .PostsScreen li h3 { 48 | padding: 10px; 49 | margin: 0; 50 | font-size: 16px; 51 | } 52 | 53 | .PostsScreen .thumbnail { 54 | max-width: 60px; 55 | } 56 | 57 | .PostsScreen .TopicFilter { 58 | height: 40px; 59 | } 60 | 61 | .PostsScreen .TopicFilter a { 62 | margin: 0 15px; 63 | line-height: 40px; 64 | color: blue; 65 | text-decoration: none; 66 | } 67 | 68 | .PostsScreen .TopicFilter a.selected { 69 | 70 | color: grey; 71 | } 72 | -------------------------------------------------------------------------------- /src/containers/PostsScreen.js: -------------------------------------------------------------------------------- 1 | // containers are "smart" react components that are aware of redux 2 | // they are connected to the redux store and listen on part of the app state 3 | // they use mapStateToProps to specify which parts and use selectors to read them 4 | // avoid having view logic & local component state in them, use "dumb" components instead 5 | 6 | import React, { Component } from 'react'; 7 | import autoBind from 'react-autobind'; 8 | import { connect } from 'react-redux'; 9 | import './PostsScreen.css'; 10 | import * as postsActions from '../store/posts/actions'; 11 | import * as postsSelectors from '../store/posts/reducer'; 12 | import * as topicsSelectors from '../store/topics/reducer'; 13 | import ListView from '../components/ListView'; 14 | import ListRow from '../components/ListRow'; 15 | import TopicFilter from '../components/TopicFilter'; 16 | import PostView from '../components/PostView'; 17 | 18 | class PostsScreen extends Component { 19 | 20 | constructor(props) { 21 | super(props); 22 | autoBind(this); 23 | } 24 | 25 | render() { 26 | if (!this.props.postsById) return this.renderLoading(); 27 | return ( 28 |
    29 |
    30 | 36 | 40 |
    41 |
    42 | 43 |
    44 |
    45 | ); 46 | } 47 | 48 | renderLoading() { 49 | return ( 50 |

    Loading...

    51 | ); 52 | } 53 | 54 | renderRow(postId, post) { 55 | const selected = this.props.currentPost === post; 56 | return ( 57 | 61 | {!post.thumbnail ? false : 62 | thumbnail 63 | } 64 |

    {post.title}

    65 |
    66 | ) 67 | } 68 | 69 | onFilterChanged(newFilter) { 70 | this.props.dispatch(postsActions.changeFilter(newFilter)); 71 | } 72 | 73 | onRowClick(postId) { 74 | this.props.dispatch(postsActions.selectPost(postId)); 75 | } 76 | 77 | } 78 | 79 | // which props do we want to inject, given the global store state? 80 | // always use selectors here and avoid accessing the state directly 81 | function mapStateToProps(state) { 82 | const [postsById, postsIdArray] = postsSelectors.getPosts(state); 83 | return { 84 | postsById, 85 | postsIdArray, 86 | topicsByUrl: topicsSelectors.getSelectedTopicsByUrl(state), 87 | currentFilter: postsSelectors.getCurrentFilter(state), 88 | currentPost: postsSelectors.getCurrentPost(state) 89 | }; 90 | } 91 | 92 | export default connect(mapStateToProps)(PostsScreen); 93 | -------------------------------------------------------------------------------- /src/containers/TopicsScreen.css: -------------------------------------------------------------------------------- 1 | .TopicsScreen { 2 | padding: 20px; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .TopicsScreen ul { 8 | list-style: none; 9 | padding-left: 0; 10 | width: 640px; 11 | margin: auto; 12 | border: 1px solid #aaa; 13 | max-height: calc(100vh - 110px); 14 | overflow-x: hidden; 15 | overflow-y: scroll; 16 | } 17 | 18 | .TopicsScreen li { 19 | text-align: left; 20 | border-bottom: 1px solid #aaa; 21 | cursor: pointer; 22 | } 23 | 24 | .TopicsScreen li > div { 25 | padding-left: 20px; 26 | } 27 | 28 | .TopicsScreen li h3 { 29 | padding: 10px 0 0 0; 30 | margin: 0; 31 | } 32 | 33 | .TopicsScreen li p { 34 | padding: 10px 0; 35 | margin: 0; 36 | } 37 | 38 | .NextScreen { 39 | position: fixed; 40 | top: 50%; 41 | right: calc((100vw - 640px)/2 - 90px); 42 | background: url('../../img/right_arrow.png'); 43 | background-size: 80px 80px; 44 | width: 80px; 45 | height: 80px; 46 | border: 0; 47 | cursor: pointer; 48 | outline: none; 49 | } 50 | -------------------------------------------------------------------------------- /src/containers/TopicsScreen.js: -------------------------------------------------------------------------------- 1 | // containers are "smart" react components that are aware of redux 2 | // they are connected to the redux store and listen on part of the app state 3 | // they use mapStateToProps to specify which parts and use selectors to read them 4 | // avoid having view logic & local component state in them, use "dumb" components instead 5 | 6 | import React, { Component } from 'react'; 7 | import autoBind from 'react-autobind'; 8 | import { connect } from 'react-redux'; 9 | import './TopicsScreen.css'; 10 | import * as topicsActions from '../store/topics/actions'; 11 | import * as topicsSelectors from '../store/topics/reducer'; 12 | import ListView from '../components/ListView'; 13 | import ListRow from '../components/ListRow'; 14 | 15 | class TopicsScreen extends Component { 16 | 17 | constructor(props) { 18 | super(props); 19 | autoBind(this); 20 | } 21 | 22 | componentDidMount() { 23 | this.props.dispatch(topicsActions.fetchTopics()); 24 | } 25 | 26 | render() { 27 | if (!this.props.topicsByUrl) return this.renderLoading(); 28 | return ( 29 |
    30 |

    Choose 3 topics of interest

    31 | 35 | {!this.props.canFinalizeSelection ? false : 36 |
    39 | ); 40 | } 41 | 42 | renderLoading() { 43 | return ( 44 |

    Loading...

    45 | ); 46 | } 47 | 48 | renderRow(topicUrl, topic) { 49 | const selected = this.props.selectedTopicsByUrl[topicUrl]; 50 | return ( 51 | 55 |

    {topic.title}

    56 |

    {topic.description}

    57 |
    58 | ) 59 | } 60 | 61 | onRowClick(topicUrl) { 62 | this.props.dispatch(topicsActions.selectTopic(topicUrl)); 63 | } 64 | 65 | onNextScreenClick() { 66 | this.props.dispatch(topicsActions.finalizeTopicSelection()); 67 | } 68 | 69 | } 70 | 71 | // which props do we want to inject, given the global store state? 72 | // always use selectors here and avoid accessing the state directly 73 | function mapStateToProps(state) { 74 | const [topicsByUrl, topicsUrlArray] = topicsSelectors.getTopics(state); 75 | return { 76 | topicsByUrl, 77 | topicsUrlArray, 78 | selectedTopicsByUrl: topicsSelectors.getSelectedTopicsByUrl(state), 79 | canFinalizeSelection: topicsSelectors.isTopicSelectionValid(state) 80 | }; 81 | } 82 | 83 | export default connect(mapStateToProps)(TopicsScreen); 84 | -------------------------------------------------------------------------------- /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 { createStore, applyMiddleware, combineReducers } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import thunk from 'redux-thunk'; 6 | import App from './App'; 7 | import './index.css'; 8 | 9 | import * as reducers from './store/reducers'; 10 | const store = createStore(combineReducers(reducers), applyMiddleware(thunk)); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | -------------------------------------------------------------------------------- /src/services/reddit.js: -------------------------------------------------------------------------------- 1 | // services are state-less 2 | // they act as utility facades that abstract the details for complex operations 3 | // normally, our interface to any sort of server API will be as a service 4 | 5 | import _ from 'lodash'; 6 | 7 | const REDDIT_ENDPOINT = 'https://www.reddit.com'; 8 | 9 | class RedditService { 10 | 11 | async getDefaultSubreddits() { 12 | const url = `${REDDIT_ENDPOINT}/subreddits/default.json`; 13 | const response = await fetch(url, { 14 | method: 'GET', 15 | headers: { 16 | Accept: 'application/json' 17 | } 18 | }); 19 | if (!response.ok) { 20 | throw new Error(`RedditService getDefaultSubreddits failed, HTTP status ${response.status}`); 21 | } 22 | const data = await response.json(); 23 | const children = _.get(data, 'data.children'); 24 | if (!children) { 25 | throw new Error(`RedditService getDefaultSubreddits failed, children not returned`); 26 | } 27 | const sortedBySubscribers = _.orderBy(children, 'data.subscribers', 'desc'); 28 | return _.map(sortedBySubscribers, (subreddit) => { 29 | // abstract away the specifics of the reddit API response and take only the fields we care about 30 | return { 31 | title: _.get(subreddit, 'data.display_name'), 32 | description: _.get(subreddit, 'data.public_description'), 33 | url: _.get(subreddit, 'data.url') 34 | } 35 | }); 36 | } 37 | 38 | async getPostsFromSubreddit(subredditUrl) { 39 | const url = `${REDDIT_ENDPOINT}${subredditUrl}hot.json`; 40 | const response = await fetch(url, { 41 | method: 'GET', 42 | headers: { 43 | Accept: 'application/json' 44 | } 45 | }); 46 | if (!response.ok) { 47 | throw new Error(`RedditService getPostsFromSubreddit failed, HTTP status ${response.status}`); 48 | } 49 | const data = await response.json(); 50 | const children = _.get(data, 'data.children'); 51 | if (!children) { 52 | throw new Error(`RedditService getPostsFromSubreddit failed, children not returned`); 53 | } 54 | return _.map(children, (post) => { 55 | // abstract away the specifics of the reddit API response and take only the fields we care about 56 | const body = _.get(post, 'data.selftext'); 57 | return { 58 | id: _.get(post, 'data.id'), 59 | title: _.get(post, 'data.title'), 60 | topicUrl: subredditUrl, 61 | body: body, 62 | thumbnail: this._validateUrl(_.get(post, 'data.thumbnail')), 63 | url: !body ? this._validateUrl(_.get(post, 'data.url')) : undefined 64 | } 65 | }); 66 | } 67 | 68 | _validateUrl(url = '') { 69 | return url.startsWith('http') ? url : undefined; 70 | } 71 | 72 | } 73 | 74 | export default new RedditService(); 75 | -------------------------------------------------------------------------------- /src/store/posts/actionTypes.js: -------------------------------------------------------------------------------- 1 | // strings should be unique across reducers so namespace them with the reducer name 2 | 3 | export const POSTS_FETCHED = 'posts.POSTS_FETCHED'; 4 | export const FILTER_CHANGED = 'posts.FILTER_CHANGED'; 5 | export const POST_SELECTED = 'posts.POST_SELECTED'; 6 | -------------------------------------------------------------------------------- /src/store/posts/actions.js: -------------------------------------------------------------------------------- 1 | // actions are where most of the business logic takes place 2 | // they are dispatched by views or by other actions 3 | // there are 3 types of actions: 4 | // async thunks - when doing asynchronous business logic like accessing a service 5 | // sync thunks - when you have substantial business logic but it's not async 6 | // plain object actions - when you just send a plain action to the reducer 7 | 8 | import _ from 'lodash'; 9 | import * as types from './actionTypes'; 10 | import redditService from '../../services/reddit'; 11 | import * as topicsSelectors from '../topics/reducer'; 12 | 13 | export function fetchPosts() { 14 | return async(dispatch, getState) => { 15 | try { 16 | const selectedTopicUrls = topicsSelectors.getSelectedTopicUrls(getState()); 17 | const fetchPromises = _.map(selectedTopicUrls, (topicUrl) => redditService.getPostsFromSubreddit(topicUrl)); 18 | const topicPosts = await Promise.all(fetchPromises); 19 | const postsById = _.keyBy(_.shuffle(_.flatten(topicPosts)), (post) => post.id); 20 | dispatch({ type: types.POSTS_FETCHED, postsById }); 21 | } catch (error) { 22 | console.error(error); 23 | } 24 | }; 25 | } 26 | 27 | export function changeFilter(newFilter) { 28 | return({ type: types.FILTER_CHANGED, filter: newFilter }); 29 | } 30 | 31 | export function selectPost(postId) { 32 | return({ type: types.POST_SELECTED, postId }); 33 | } 34 | -------------------------------------------------------------------------------- /src/store/posts/reducer.js: -------------------------------------------------------------------------------- 1 | // reducers hold the store's state (the initialState object defines it) 2 | // reducers also handle plain object actions and modify their state (immutably) accordingly 3 | // this is the only way to change the store's state 4 | // the other exports in this file are selectors, which is business logic that digests parts of the store's state 5 | // for easier consumption by views 6 | 7 | import _ from 'lodash'; 8 | import * as types from './actionTypes'; 9 | import Immutable from 'seamless-immutable'; 10 | import * as topicsSelectors from '../topics/reducer'; 11 | 12 | const initialState = Immutable({ 13 | postsById: undefined, 14 | currentFilter: 'all', 15 | currentPostId: undefined 16 | }); 17 | 18 | export default function reduce(state = initialState, action = {}) { 19 | switch (action.type) { 20 | case types.POSTS_FETCHED: 21 | return state.merge({ 22 | postsById: action.postsById 23 | }); 24 | case types.FILTER_CHANGED: 25 | return state.merge({ 26 | currentFilter: action.filter 27 | }); 28 | case types.POST_SELECTED: 29 | return state.merge({ 30 | currentPostId: action.postId 31 | }); 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | // selectors 38 | 39 | export function getPosts(state) { 40 | const currentFilter = state.posts.currentFilter; 41 | const postsById = state.posts.postsById; 42 | const currentTopicUrls = topicsSelectors.getSelectedTopicsByUrl(state); 43 | const postsIdArray = currentFilter === 'all' ? 44 | _.filter(_.keys(postsById), (postId) => currentTopicUrls[postsById[postId].topicUrl]) : 45 | _.filter(_.keys(postsById), (postId) => postsById[postId].topicUrl === currentFilter); 46 | return [postsById, postsIdArray]; 47 | } 48 | 49 | export function getCurrentFilter(state) { 50 | return state.posts.currentFilter; 51 | } 52 | 53 | export function getCurrentPost(state) { 54 | return _.get(state.posts.postsById, state.posts.currentPostId); 55 | } 56 | -------------------------------------------------------------------------------- /src/store/reducers.js: -------------------------------------------------------------------------------- 1 | import topics from './topics/reducer'; 2 | import posts from './posts/reducer'; 3 | 4 | export { 5 | topics, 6 | posts 7 | }; 8 | -------------------------------------------------------------------------------- /src/store/topics/actionTypes.js: -------------------------------------------------------------------------------- 1 | // strings should be unique across reducers so namespace them with the reducer name 2 | 3 | export const TOPICS_FETCHED = 'topics.TOPICS_FETCHED'; 4 | export const TOPICS_SELECTED = 'topics.TOPICS_SELECTED'; 5 | export const TOPIC_SELECTION_FINALIZED = 'topics.TOPIC_SELECTION_FINALIZED'; 6 | -------------------------------------------------------------------------------- /src/store/topics/actions.js: -------------------------------------------------------------------------------- 1 | // actions are where most of the business logic takes place 2 | // they are dispatched by views or by other actions 3 | // there are 3 types of actions: 4 | // async thunks - when doing asynchronous business logic like accessing a service 5 | // sync thunks - when you have substantial business logic but it's not async 6 | // plain object actions - when you just send a plain action to the reducer 7 | 8 | import _ from 'lodash'; 9 | import * as types from './actionTypes'; 10 | import redditService from '../../services/reddit'; 11 | import * as topicsSelectors from './reducer'; 12 | import * as postActions from '../posts/actions'; 13 | 14 | export function fetchTopics() { 15 | return async(dispatch, getState) => { 16 | try { 17 | const subredditArray = await redditService.getDefaultSubreddits(); 18 | const topicsByUrl = _.keyBy(subredditArray, (subreddit) => subreddit.url); 19 | dispatch({ type: types.TOPICS_FETCHED, topicsByUrl }); 20 | } catch (error) { 21 | console.error(error); 22 | } 23 | }; 24 | } 25 | 26 | export function selectTopic(topicUrl) { 27 | return (dispatch, getState) => { 28 | const selectedTopics = topicsSelectors.getSelectedTopicUrls(getState()); 29 | let newSelectedTopics; 30 | if (_.indexOf(selectedTopics, topicUrl) !== -1) { 31 | newSelectedTopics = _.without(selectedTopics, topicUrl); 32 | } else { 33 | newSelectedTopics = selectedTopics.length < 3 ? 34 | selectedTopics.concat(topicUrl) : 35 | selectedTopics.slice(1).concat(topicUrl); 36 | } 37 | dispatch({ type: types.TOPICS_SELECTED, selectedTopicUrls: newSelectedTopics }); 38 | // optimization - prefetch the posts before going to the posts screen 39 | if (newSelectedTopics.length === 3) { 40 | dispatch(postActions.fetchPosts()); 41 | } 42 | }; 43 | } 44 | 45 | export function finalizeTopicSelection() { 46 | return({ type: types.TOPIC_SELECTION_FINALIZED }); 47 | } 48 | -------------------------------------------------------------------------------- /src/store/topics/reducer.js: -------------------------------------------------------------------------------- 1 | // reducers hold the store's state (the initialState object defines it) 2 | // reducers also handle plain object actions and modify their state (immutably) accordingly 3 | // this is the only way to change the store's state 4 | // the other exports in this file are selectors, which is business logic that digests parts of the store's state 5 | // for easier consumption by views 6 | 7 | import _ from 'lodash'; 8 | import * as types from './actionTypes'; 9 | import Immutable from 'seamless-immutable'; 10 | 11 | const initialState = Immutable({ 12 | topicsByUrl: undefined, 13 | selectedTopicUrls: [], 14 | selectionFinalized: false 15 | }); 16 | 17 | export default function reduce(state = initialState, action = {}) { 18 | switch (action.type) { 19 | case types.TOPICS_FETCHED: 20 | return state.merge({ 21 | topicsByUrl: action.topicsByUrl 22 | }); 23 | case types.TOPICS_SELECTED: 24 | return state.merge({ 25 | selectedTopicUrls: action.selectedTopicUrls 26 | }); 27 | case types.TOPIC_SELECTION_FINALIZED: 28 | return state.merge({ 29 | selectionFinalized: true 30 | }); 31 | default: 32 | return state; 33 | } 34 | } 35 | 36 | // selectors 37 | 38 | export function getTopics(state) { 39 | const topicsByUrl = state.topics.topicsByUrl; 40 | const topicsUrlArray = _.keys(topicsByUrl); 41 | return [topicsByUrl, topicsUrlArray]; 42 | } 43 | 44 | export function getSelectedTopicUrls(state) { 45 | return state.topics.selectedTopicUrls; 46 | } 47 | 48 | export function getSelectedTopicsByUrl(state) { 49 | return _.mapValues(_.keyBy(state.topics.selectedTopicUrls), (topicUrl) => state.topics.topicsByUrl[topicUrl]); 50 | } 51 | 52 | export function isTopicSelectionValid(state) { 53 | return state.topics.selectedTopicUrls.length === 3; 54 | } 55 | 56 | export function isTopicSelectionFinalized(state) { 57 | return state.topics.selectionFinalized; 58 | } 59 | --------------------------------------------------------------------------------