Slide 1

Slide 1 text

React @ Product Hunt Radoslav Stankov 12/05/2017

Slide 2

Slide 2 text

Radoslav Stankov @rstankov http://rstankov.com http://github.com/rstankov

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

Developer Team

Slide 5

Slide 5 text

October 2014 Backbone early 2014 jQuery spaghetti February 2014 React on Rails May 2014 custom Flux December 2015 Redux January 2016 React-Router April 2016 Redux Ducks January 2017 Frontend app February 2017 GraphQL March 2017 Code splitting

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

html html json

Slide 9

Slide 9 text

json json

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 13

Slide 13 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 14

Slide 14 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 15

Slide 15 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 16

Slide 16 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 17

Slide 17 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 18

Slide 18 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 19

Slide 19 text

https://github.com/facebook/flow

Slide 20

Slide 20 text

export type Topic = { id: number, image_uuid?: ?string, name: string, description?: string, followers_count: number, posts_count: number, slug: string, };

Slide 21

Slide 21 text

function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; }

Slide 22

Slide 22 text

function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);

Slide 23

Slide 23 text

function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);

Slide 24

Slide 24 text

function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse); const variable: number = isFollowing(user, topic);

Slide 25

Slide 25 text

function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse); const variable: number = isFollowing(user, topic);

Slide 26

Slide 26 text

function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse); const variable: number = isFollowing(user, topic); const variable: boolean = isFollowing(user, topic); ! "

Slide 27

Slide 27 text

function isFollowing( user: { followedTopicsIds: Array }, topic: { id: number } ): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; }

Slide 28

Slide 28 text

Slide 29

Slide 29 text

Slide 30

Slide 30 text

Slide 31

Slide 31 text

class UserImage extends React.Component { props: {| user: User, width: number, height: number |} | {| user: User, variant: 'big' | 'medium' | 'small' |}; render() { /* ... */ } }

Slide 32

Slide 32 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 35

Slide 35 text

Ducks: Redux Reducer Bundles 
 https://github.com/erikras/ducks-modular-redux


Slide 36

Slide 36 text

/modules/anchor.js /modules/context.js /modules/cookies.js /modules/currentUser.js /modules/notice.js /modules/seed.js /modules/visitedTopics.js

Slide 37

Slide 37 text

modules/[reducer].js 1. Action Constants 2. Action Creators 3. Reducer 4. Selectors 5. Other stuff ¯\_(ϑ)_/¯

Slide 38

Slide 38 text

modules/[reducer].js 1. Action Constants 2. Action Creators 3. Reducer 4. Selectors 5. Other stuff ¯\_(ϑ)_/¯

Slide 39

Slide 39 text

import { createReducer } from 'ph/redux'; import type { Dispatchable, Reducer } from 'ph/types/redux'; import type { Notice } from 'ph/types/Notice'; // Constants const RECEIVE_NOTICE = 'NOTICE/RECEIVE'; // Actions export function receiveNotice(notice: Notice): Dispatchable { return { type: RECEIVE_NOTICE, payload: { notice } }; } export function clearNotice(): Dispatchable { return { type: RECEIVE_NOTICE, payload: { notice: null } }; } // Reducer
 const reducer: Reducer = function createReducer(null, { [RECEIVE_NOTICE]: (state, { payload: { notice } }) => notice, });
 export default reducer;

Slide 40

Slide 40 text

import createTestStore from 'ph/createTestStore'; import { clearNotice, receiveNotice } from 'ph/modules/notice'; describe('notice', () => { const getState = (store) => store.getState().notice; describe(receiveNotice.name, () => { it('sets a notice object', () => { const store = createTestStore(); const notice = { type: 'notice', message: 'submitted' } store.dispatch(receiveNotice(notice)); expect(getState(store)).to.deep.equal(notice); }); }); describe(clearNotice.name, () => { it('sets a notice object', () => { const store = createTestStore(); store.dispatch(clearNotice()); expect(getState(store)).to.equal(null); }); }); });

Slide 41

Slide 41 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 42

Slide 42 text

No content

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

No content

Slide 45

Slide 45 text


 
 


Slide 46

Slide 46 text


 
 


Slide 47

Slide 47 text


 
 


Slide 48

Slide 48 text


 
 


Slide 49

Slide 49 text


 
 


Slide 50

Slide 50 text


 
 


Slide 51

Slide 51 text


 
 


Slide 52

Slide 52 text


 
 


Slide 53

Slide 53 text

Slide 54

Slide 54 text

Slide 55

Slide 55 text

Slide 56

Slide 56 text

Slide 57

Slide 57 text

Slide 58

Slide 58 text

Slide 59

Slide 59 text

Slide 60

Slide 60 text

export default function TopicItem({ topic }: { topic: Topic }) { return (
{topic.name} {topic.description}
); }

Slide 61

Slide 61 text

export default function TopicItem({ topic }: { topic: Topic }) { return (
{topic.name} {topic.description}
); }

Slide 62

Slide 62 text

CSS // style.css .item { border-top: 1px solid $grey; } CSS // component.js
 import styles from './styles.css';

Slide 63

Slide 63 text

CSS // style.css .item { border-top: 1px solid $grey; } CSS // component.js
 import styles from './styles.css';

Slide 64

Slide 64 text

CSS // style.css .item { border-top: 1px solid $grey; } CSS // component.js
 import styles from './styles.css';
CSS // style.css .item_3mRwv { border-top: 1px solid $grey; } CSS // result.js

Slide 65

Slide 65 text

export default function TopicItem({ topic }: { topic: Topic }) { return (
{topic.name} {topic.description}
); }

Slide 66

Slide 66 text

export default function TopicItem({ topic }: { topic: Topic }) { return (
{topic.name} {topic.description}
); }

Slide 67

Slide 67 text

… … … … … … …

Slide 68

Slide 68 text

export default function TopicItem({ topic }: { topic: Topic }) { return (
{topic.name} {topic.description}
); }

Slide 69

Slide 69 text

export default function TopicItem({ topic }: { topic: Topic }) { return (
{topic.name} {topic.description}
); }

Slide 70

Slide 70 text

export class TopicFollowButton extends React.Component { props: { topic: Topic, isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( {this.props.topic.isFollowed ? 'Following' : 'Follow'} ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }

Slide 71

Slide 71 text

export class TopicFollowButton extends React.Component { props: { topic: Topic, isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( {this.props.topic.isFollowed ? 'Following' : 'Follow'} ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }

Slide 72

Slide 72 text

export class TopicFollowButton extends React.Component { props: { topic: Topic, isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( {this.props.topic.isFollowed ? 'Following' : 'Follow'} ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }

Slide 73

Slide 73 text

export class TopicFollowButton extends React.Component { props: { topic: Topic, isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( {this.props.topic.isFollowed ? 'Following' : 'Follow'} ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }

Slide 74

Slide 74 text

export class TopicFollowButton extends React.Component { props: { topic: Topic, isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( {this.props.topic.isFollowed ? 'Following' : 'Follow'} ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }

Slide 75

Slide 75 text

reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; } export default createContainer({ renderComponent: TopicFollowButton, actionCreators: { openLoginFullscreen, }, mapStateToProps({ currentUser }) { return { isLogged: currentUser.isLogged }; }, decorators: [ graphql(CREATE_MUTATION, { /* ... */ }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }

Slide 76

Slide 76 text

export default createContainer({ renderComponent: Component, actionCreators: { action1, action2, action2 }, mapStateToProps: ({ currentUser }) => ({ currentUser }), }); export connect(mapStateToProps, actionCreators)(Component)

Slide 77

Slide 77 text

export default createContainer({ renderComponent: Component, decorators: [ renderNotFound(someCondition), renderLoading(), addBodyClassName('white-background') documentTitle('About page'), requireEnabledFeature('new_topics') // ... ] });

Slide 78

Slide 78 text

export default createContainer({ renderComponent: TopicFollowButton, actionCreators: { openLoginFullscreen, }, mapStateToProps({ currentUser }) { return { isLogged: currentUser.isLogged }; }, decorators: [ graphql(CREATE_MUTATION, { /* ... */ }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }

Slide 79

Slide 79 text

export default createContainer({ renderComponent: TopicFollowButton, actionCreators: { openLoginFullscreen, }, mapStateToProps({ currentUser }) { return { isLogged: currentUser.isLogged }; }, decorators: [ graphql(CREATE_MUTATION, { /* ... */ }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }

Slide 80

Slide 80 text


 http://graphql.org/


Slide 81

Slide 81 text


 query { topic(id: 1) { id name description isFollowed image } } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }

Slide 82

Slide 82 text

query { topic(id: 1) { id ...Item } } fragment Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }

Slide 83

Slide 83 text

query { topic(id: 1) { id ...Item } } fragment Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }

Slide 84

Slide 84 text

query { topic(id: 1) { id ...Item } } fragment Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }

Slide 85

Slide 85 text

query { topic(id: 1) { id ...Item } } fragment Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }

Slide 86

Slide 86 text


 mutation FollowTopic($input) { followTopic(input: $input) { node { id isFollowed } } } POST /graphql { "data": {
 "followTopic": { "node": { "id": 1, "isFollowed": true } } } }

Slide 87

Slide 87 text


 http://www.apollodata.com/


Slide 88

Slide 88 text

import CREATE_MUTATION from './FollowTopic.graphql';
 import REMOVE_MUTATION from './UnfollowTopic.graphql';
 
 // ... 
 export default createContainer({ // ... decorators: [ graphql(CREATE_MUTATION, { props: ({ ownProps: { topic: { id } }, mutate }) => ({ followTopic() { return mutate({ variables: { input: { id }, }, optimisticResponse: { response: { node: { __typename: 'Topic', id, isFollowed: true }, }, } }); }, }), }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }

Slide 89

Slide 89 text

// components/FollowButton/FollowTopic.graphql mutation FollowTopic($input: FollowTopicInput!) { followTopic(input: $input) { node { id isFollowed } } }

Slide 90

Slide 90 text

import React from 'react'; import sinon from 'sinon'; import TopicFactory from 'factories/TopicFactory';
 import { Component } from 'ph/components/TopicFollowButton'; describe(Component.name, () => { const followed = new TopicFactory({ isFollowed: true }); const unfollowed = new TopicFactory({ isFollowed: false }); const defaults = { topic: unfollowed, isLogged: true, openLoginFullscreen() {}, followTopic() {}, unfollowTopic() {}, }; const call = sut(defaults, (opts) => ); it('renders "Follow" text when is not following', () => { const wrapper = shallow(call({ topic: followed })); expect(wrapper).to.contain('Follow'); }); it('renders "Following" text when is following', () => { const wrapper = shallow(call({ topic: unfollowed })); expect(wrapper).to.contain('Following'); }); it('can follow a topic', () => { const spy = sinon.spy(); const wrapper = shallow(call({ followTopic: spy, topic: followed }));

Slide 91

Slide 91 text

expect(wrapper).to.contain('Following'); }); it('can follow a topic', () => { const spy = sinon.spy(); const wrapper = shallow(call({ followTopic: spy, topic: followed })); wrapper.simulate('click', { preventDefault: () => {} }); expect(spy).to.have.been.calledOnce; }); it('can unfollow a topic', () => { const spy = sinon.spy(); const wrapper = shallow(call({ unfollowTopic: spy, topic: followed })); wrapper.simulate('click', { preventDefault: () => {} }); expect(spy).to.have.been.calledOnce; }); it('calls openLoginFullscreen when user is not logged', () => { const spy = sinon.spy(); const wrapper = shallow(call({ handleNotLogged: spy, isLogged: false, })); wrapper.simulate('click', { preventDefault: () => {} }); expect(spy).to.have.been.calledOnce; }); });

Slide 92

Slide 92 text


 https://github.com/producthunt/chai-enzyme


Slide 93

Slide 93 text

/components/TopicFollowButton/FollowTopic.graphql /components/TopicFollowButton/Fragment.graphql /components/TopicFollowButton/UnollowTopic.graphql /components/TopicFollowButton/index.js /components/TopicItem/Fragment.graphql /components/TopicItem/index.js /components/TopicItem/styles.js

Slide 94

Slide 94 text

// components/TopicItem/Fragment.graphql #import "ph/components/TopicFollowButton/TopicFollowButton.graphql" #import "ph/components/TopicImage/TopicImage.graphql" fragment TopicItem on Topic { id name slug description ...TopicFollowButton ...TopicImage }

Slide 95

Slide 95 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 96

Slide 96 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 97

Slide 97 text

v3 
 https://github.com/ReactTraining/react-router


Slide 98

Slide 98 text


 https://webpack.js.org/guides/code-splitting-async


Slide 99

Slide 99 text

export default { component: Main, childRoutes: [ { path: '/', track: 'home', getComponent(_, cb) { require.ensure([], (require) => cb(null, require('ph/pages/Homepage').default)); }, }, { path: '/topics', track: 'topic_index', getComponent(_, cb) { require.ensure([], (require) => cb(null, require('ph/pages/Topics').default)); }, }, { path: '/topics/:slug', track: 'topic_detail', getComponent(_, cb) { require.ensure([], (require) => cb(null, require('ph/pages/Topic').default)); }, }, // ... ], };

Slide 100

Slide 100 text

export default function createReduxStore(initialState = {}, { apollo } = {}) { const middlewares = [ reduxReactRouter({ createHistory: canUseDOM ? createBrowserHistory : createMemoryHistory, routes, }), applyMiddleware(analytics), ]; if (apollo) { middlewares.push(applyMiddleware(apollo.middleware())); } if (canUseDOM && window.localStorage) { middlewares.push(applyMiddleware(persistState())); } if (canUseDOM && window.devToolsExtension) { middlewares.push(window.devToolsExtension()); } const finalReducers = apollo ? { ...reducers, apollo: apollo.reducer() } : reducers; const finalStore = compose(...middlewares)(createStore); return finalStore(combineReducers(finalReducers), initialState); }

Slide 101

Slide 101 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 102

Slide 102 text

export default { root(): string { return '/'; }, topics: { index(): string { return '/topics'; }, show({ slug }: Topic): string { return `/topics/${ slug }`; }, }, pages: { about(): string { return '/about'; }, // ... }
 // ... };

Slide 103

Slide 103 text

import paths from 'ph/paths';
 paths.topics.index(); // => /topics/ paths.topics.show(topic); // => /topics/developer-tools
 paths.pages.about(); // => /about/

Slide 104

Slide 104 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 105

Slide 105 text

No content

Slide 106

Slide 106 text


 {children}

Slide 107

Slide 107 text


 {children}

Slide 108

Slide 108 text


 {children}

Slide 109

Slide 109 text


 {children}

Slide 110

Slide 110 text


 {children}

Slide 111

Slide 111 text


 {children}

Slide 112

Slide 112 text


 {children}

Slide 113

Slide 113 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 114

Slide 114 text

No content

Slide 115

Slide 115 text

No content

Slide 116

Slide 116 text

No content

Slide 117

Slide 117 text

Header Media Recommended Posts Makers Discussion

Slide 118

Slide 118 text

/pages/Post/Discussion/Fragment.graphql /pages/Post/Discussion/index.js /pages/Post/Discussion/styles.css /pages/Post/RecommendedPosts/Fragment.graphql /pages/Post/RecommendedPosts/index.js /pages/Post/RecommendedPosts/styles.css /pages/Post/RecommendedPosts/Item/... /pages/Post/Header/... /pages/Post/Makers/... /pages/Post/Media/... /pages/Post/Query.graphql /pages/Post/index.js /pages/Post/styles.js

Slide 119

Slide 119 text

// pages/Post/Discussion/Fragment.graphql
 fragment PostDiscussion on Post { comments { id created_at parent_comment_id state votes_count body user { id username name headline } } }

Slide 120

Slide 120 text

// pages/Post/RecommendedPosts/Fragment.graphql #import "ph/pages/Post/RecommendedPosts/Item/Fragment.graphql" fragment PostRecommendedPosts on Post { recommended_posts(first: 10) { edges { node { ...PostRecommendedPostsItem } } } }

Slide 121

Slide 121 text

// pages/Post/PostPage.graphql #import "ph/lib/meta/MetaTags.graphql" #import "ph/pages/Post/Discussion/Fragment.graphql" #import "ph/pages/Post/Header/Fragment.graphql" #import "ph/pages/Post/Makers/Fragment.graphql" #import "ph/pages/Post/Media/Fragment.graphql" #import "ph/pages/Post/RecommendedPosts/Fragment.graphql" query PostPage($id: String!) { post(id: $id) { id
 ...MetaTags ...PostDiscussion ...PostHeader ...PostMedia ...PostRecommendedPosts } }

Slide 122

Slide 122 text

import QUERY from './Query.graphql'; import { graphql } from 'react-apollo'; // ... export default createContainer({ renderComponent: Content, decorators: [ setTags(({ data: { post: { meta } } }) => meta), visibleIf(({ data }) => !!data.post),
 deferredRender(), graphql(QUERY, { options: ({ params: { id } }) => ({ variables: { id, }, }), }), ], });

Slide 123

Slide 123 text

components/ graphql/
 layouts/ lib/ modules/ pages/ styles/ types/ utils/
 config.js entry.js paths.js reducers.js routes.js

Slide 124

Slide 124 text

No content

Slide 125

Slide 125 text

https://speakerdeck.com/rstankov/react-at-product-hunt-wad

Slide 126

Slide 126 text

# https://github.com/acdlite/redux-router $ https://github.com/airbnb/enzyme/ % https://github.com/apollographql & https://github.com/chaijs/chai ' https://github.com/css-modules/css-modules ( https://github.com/DylanPiercey/require-ensure ) https://github.com/erikras/ducks-modular-redux * https://github.com/facebook/flow + https://github.com/graphql , https://github.com/producthunt/chai-enzyme - https://github.com/ReactTraining/react-router . https://webpack.js.org/guides/code-splitting-async

Slide 127

Slide 127 text

Thanks !

Slide 128

Slide 128 text

No content