├── .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 |
20 | {_.map(this.props.rowsIdArray, this.renderRowById)}
21 |
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 |
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 |
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 |
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 |
37 | }
38 |
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 |
--------------------------------------------------------------------------------