React at Product Hunt

React at Product Hunt

At ProductHunt we use React for almost 2 years. In the talk, I'm going to share some of the things we learned during that period.

7a0e72a6f55811246bb5d9a946fd2e49?s=128

Radoslav Stankov

November 19, 2016
Tweet

Transcript

  1. React at Product Hunt Radoslav Stankov 19/11/2016

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

  3. None
  4. None
  5. Developer Team

  6. Tech Stack

  7. Tech Stack

  8. None
  9. Tech Stack ES2016 BABEL ESLINT FLOW

  10. early 2014 jQuery spaghetti October 2014 Backbone February 2015 React

    & Rails May 2015 custom Flux
  11. December 2015 Redux January 2016 React-Router April 2016 Redux Ducks

    October 2016 JSTalks talk :)
  12. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

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

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

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

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

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

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

    entry.js paths.js reducers.js routes.js
  19. https://github.com/facebook/flow

  20. export type Topic = { id: number, image_uuid?: ?string, name:

    string, description?: string, followers_count: number, posts_count: number, slug: string, };
  21. function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !==

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

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

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);
  24. 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);
  25. 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);
  26. 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);
  27. function isFollowing( user: { followedTopicsIds: Array<number> }, topic: { id:

    number } ): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; }
  28. <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" />

  29. <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" /> <UserImage

    user={user} width={50} variant="small" /> <UserImage user={user} width={50} height={30} variant="small" />
  30. <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" /> <UserImage

    user={user} width={50} variant="small" /> <UserImage user={user} width={50} height={30} variant="small" />
  31. <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" /> <UserImage

    user={user} width={50} variant="small" /> <UserImage user={user} width={50} height={30} variant="small" /> class UserImage extends React.Component { props: {| user: User, width: number, height: number |} | {| user: User, variant: 'big' | 'medium' | 'small' |}; render() { /* ... */ } }
  32. https://github.com/facebook/flow

  33. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  34. https://github.com/producthunt/duxy

  35. const http = createHttpAdapter(); export default duxy({ http }, ({

    resources, resource, get }) => { get('context'); resources('topics', { only: ['findAll', 'findOne'] }, () => { resource('topContributors', { path: 'top_contributors', only: ['findAll'] }); resource('followers', { only: ['create', 'delete'] }); }); // ... more definitions });
  36. import api from 'ph/api'; const response = await api.context(); //

    GET /context const response = await api.topics.findOne({ id: 1 }) // GET /topics/1 const response = await api.topics.findAll({ limit: 10 }) // GET /topics?limit=10
  37. import api from 'ph/api';
 
 try { // POST /topics/1/followers

    const response = await api.topics.followers.create({ topicId: 1 }); } catch(e) { // handle errors }
  38. ⚽ resources - findAll / findOne / create / update

    / delete resource - findAll / create / update / delete get ⚾ post patch delete namespace adapters Duxy
  39. https://github.com/producthunt/duxy

  40. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  41. None
  42. None
  43. None
  44. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  45. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  46. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  47. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  48. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  49. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  50. <TopicsPage> <InfiniteScroll> <Box> <TopicItemList> <TopicItem /> </TopicItemList> </Box> </InfiniteScroll> </TopicsPage>

  51. function TopicItemList({ topics }: { topics: Array<Topic> }) { return

    ( <ol className={styles.list}> {topics.map((topic) => ( <li key={topic.id}> <TopicItem topic={topic} /> </li> ))} </ol> ); }
  52. CSS // style.css .list { list-style: none; } CSS //

    component.js
 import styles from './styles.css'; <ol className={styles.list}> </ol> CSS // style.css .list_3mRwv { list-style: none; } CSS // result.js <ol className="list_3mRwv"> </ol>
  53. function TopicItemList({ topics }: { topics: Array<Topic> }) { return

    ( <ol className={styles.list}> {topics.map((topic) => ( <li key={topic.id}> <TopicItem topic={topic} /> </li> ))} </ol> ); }
  54. describe(TopicItemList.name, () => { it('renders list of topics', () =>

    { const topic = new TopicFactory(); const wrapper = shallow(<TopicItemList topics={[topic]} />) expect(wrapper).to.contain(<TopicItem topic={topic}); }); });
  55. https://github.com/producthunt/chai-enzyme

  56. None
  57. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  58. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  59. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  60. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  61. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  62. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  63. <TopicItem> <Link> <TopicImage /> <Featured /> <TopicFollowButton /> <Text />

    </Link> </TopicItem>
  64. function TopicItem({ topic }: { topic: Topic }) { return

    ( <Link className={styles.item} to={paths.topics.show(topic)}> <TopicImage className={styles.left} topic={topic} /> <div className={styles.right}> <Featured className={styles.name}> {topic.name} </Featured> <TopicFollowButton className={styles.followButton} topicId= <Text className={styles.followers} variant="subtle"> {followersCount(topic.followers_count)} </Text> </div> </Link> ); }
  65. <Headline>…</Headline> <Featured>…</Featured> <Title>…</Title> <Text>…</Text> <BoldText>…</BoldText> <SecondaryText>…</SecondaryText> <SecondaryBoldText>…</SecondaryBoldText>

  66. function TopicItem({ topic }: { topic: Topic }) { return

    ( <Link className={styles.item} to={paths.topics.show(topic)}> <TopicImage className={styles.left} topic={topic} /> <div className={styles.right}> <Featured className={styles.name}> {topic.name} </Featured> <TopicFollowButton className={styles.followButton} topicId= <Text className={styles.followers} variant="subtle"> {followersCount(topic.followers_count)} </Text> </div> </Link> ); }
  67. export class TopicFollowButton extends React.Component { props: { isFollowing: boolean,

    toggle: typeof toggleTopicFollow, topicId: number, className: string, }; render() { return ( <Button active={this.props.isFollowing} className={this.props.className} onClick={this.handleClickAction} data-test="follow-button" {this.props.isFollowing ? 'Following' : 'Follow'} </Button> ); } handleClickAction: Function = () => {
  68. export createContainer({ renderComponent: Component, actionCreators: actionCreators, mapStateToProps: mapStateToProps, }); export

    connect(mapStateToProps, actionCreators)(Component)
  69. export createContainer({ renderComponent: Component, clientFetch() { // Browser data load

    }, });
 
 class Component extends React.Component { componentDidMount() { clientFetch(); } }
  70. export createContainer({ renderComponent: Component, clientFetch(dispatch, getState, api): Promise { //

    Browser data load }, fetch(dispatch, getState, api): Promise { // Browser/Server data load }, });
  71. export createContainer({ renderComponent: Component, decorators: [ renderNotFound(someCondition), renderLoading(), addBodyClassName('white-background') documentTitle('About

    page'), requireEnabledFeature('new_topics') // ... ] });
  72. export class TopicFollowButton extends React.Component { props: { isFollowing: boolean,

    toggle: typeof toggleTopicFollow, topicId: number, className: string, }; render() { return ( <Button active={this.props.isFollowing} className={this.props.className} onClick={this.handleClickAction} data-test="follow-button" {this.props.isFollowing ? 'Following' : 'Follow'} </Button> ); } handleClickAction: Function = () => {
  73. describe(TopicFollowButton.name, () => { const call = sut(TopicFollowButton, { isFollowing:

    true, topicId: 1, toggle() {}, }); it('renders "Follow" text when is not following', () => { const wrapper = shallow(call({ isFollowing: false })); expect(wrapper).to.contain('Follow'); }); it('renders "Following" text when is following', () => { const wrapper = shallow(call({ isFollowing: true })); expect(wrapper).to.contain('Following'); }); it('can follow a topic', () => { const spy = sinon.spy(); const wrapper = shallow(call({ toggle: spy, topicId: 1 }));
  74. /components/Box/index.js /components/Button/index.js /components/Fonts/index.js /components/InfiniteScroll/index.js /components/TopicFollowButton/index.js /components/TopicItemList/Item/index.js /components/TopicItemList/index.js

  75. None
  76. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

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

    entry.js paths.js reducers.js routes.js
  78. Ducks: Redux Reducer Bundles https://github.com/erikras/ducks-modular-redux

  79. /modules/annotations.js /modules/collections.js /modules/context.js /modules/currentUser.js /modules/pages.js /modules/posts.js /modules/topics.js

  80. modules/[reducer].js 1. Actions 2. Reducer 3. Action Creators 4. Selectors

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

    5. Other stuff ¯\_(ツ)_/¯
  82. // Actions export const FOLLOW_TOPIC = 'TOPICS/FOLLOW'; export const RECEIVE_TOPICS

    = 'TOPICS/RECEIVE'; export const UNFOLLOW_TOPIC = 'TOPICS/UNFOLLOW'; // Reducer type TopicsState = Immutable.Map<number, Topic>; export const initialState: TopicsState = new Immutable.Map(); export default createReducer(initialState, { [RECEIVE_TOPICS]: (state: TopicsState, action: Action): TopicsState => { /* ... */ }, [FOLLOW_TOPIC]: (state: TopicsState, action: Action): TopicsState => { /* ... */ }, [UNFOLLOW_TOPIC]: (state: TopicsState, action: Action): TopicsState => { /*...*/ }, }); // Action Creators export function receiveTopic({ topic }: { topic: Topic }): Dispatchable { return { type: RECEIVE_TOPICS, payload: { topics: [topic] } }; } export function receiveTopics({ topics }: { topics: Array<Topic> }): Dispatchable { return { type: RECEIVE_TOPICS, payload: { topics } }; } export function toggleTopicFollow(topicId: number): Dispatchable { return function(dispatch: Dispatch, getState: Function, api: any): void { const { currentUser } = getState(); if (isFollowingTopic(currentUser, id)) {
  83. export function toggleTopicFollow(topicId: number): Dispatchable { return function(dispatch: Dispatch, getState:

    Function, api: any): void { const { currentUser } = getState(); if (isFollowingTopic(currentUser, id)) { api.topics.followers.delete({ topicId }); dispatch({ type: UNFOLLOW_TOPIC, payload: { topicId } }); } else { api.topics.followers.create({ topicId }); dispatch({ type: FOLLOW_TOPIC, payload: { topicId } }); } }; }
  84. https://github.com/gaearon/redux-thunk

  85. function action() { return { type: ACTION,
 payload: {}, };

    } redux-thunk
  86. function action() { return (dispatch, getState) => { // do

    stuff dispatch({ type: ACTION_1 }); // do stuff dispatch({ type: ACTION_2 }); // ...so on }; } redux-thunk
  87. import { applyMiddleware, compose, createStore } from 'redux'; import api

    from 'ph/api'; import reducer from 'ph/reducers'; export default function createStore(state = {}, options = {}) { const enhancers = [ applyMiddleware(thunkInject(options.api || api)), // ... other middlewares ]; return compose(...enhancers)(createStore)(reducer, initialState); }
  88. export function toggleTopicFollow(topicId: number): Dispatchable { return function(dispatch: Dispatch, getState:

    Function, api: any): void { const { currentUser } = getState(); if (isFollowingTopic(currentUser, topicId)) { api.topics.followers.delete({ topicId }); dispatch({ type: UNFOLLOW_TOPIC, payload: { topicId } }); } else { api.topics.followers.create({ topicId }); dispatch({ type: FOLLOW_TOPIC, payload: { topicId } }); } }; }
  89. describe(toggleTopicFollow.name, () => { const api = stubApi({ 'api.frontend.topics.followers.create': {},

    'api.frontend.topics.followers.delete': {}, }); const testStore = ({ topic, currentUser }) => { const store = createStore({}, { api }); store.dispatch(receiveCurrentUser(currentUser)); store.dispatch(receiveTopic(topic)); return store; } const getState = (store) => store.getState().topics; it('increases followers count on follow', () => { const topic = new TopicFactory({ followersCount: 0 }); const currentUser = new CurrentUserFactory(); const store = testStore({ topic, currentUser }); store.dispatch(toggleTopicFollow(topic)); expect(topicById(getState(store), topic.id).followersCount).to.equal(1); }); it('decreases followers count on unfollow', () => { const topic = new TopicFactory({ followersCount: 1 }); const currentUser = new CurrentUserFactory({ followedTopicIds: [topic.id] });
  90. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  91. https://github.com/ReactTraining/react-router v3

  92. // routes.js export default createRoutes( <Route component={Layouts.Main}> <Route path="/about" component={About}

    /> <Route path="/topics" component={Topics}> <Route path="/topics/:slug" component={Topic}> <Route path="/@:username" component={Layouts.Profile}> <IndexRoute component={Profile.Upvotes} /> <Route path="submitted" component={Profile.Submitted} /> <Route path="topics" component={Profile.Topics} /> </Route> </Route> );
  93. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  94. // paths.js export default { root(): string { return '/';

    }, pages: { about(): string { return '/about'; }, // ... } topics: { index(): string { return '/topics'; }, show({ slug }: Topic): string { return `/topics/${ slug }`; }, }, // ... };
  95. import paths from 'ph/paths';
 
 paths.pages.about(); // => /about/ paths.topics.index();

    // => /topics/ paths.topics.show(topic); // => /topics/developer-tools
  96. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  97. // routes.js export default createRoutes( <Route component={Layouts.Main}> <Route path="/about" component={About}

    /> <Route path="/topics" component={Topics}> <Route path="/topics/:slug" component={Topic}> <Route path="/@:username" component={Layouts.Profile}> <IndexRoute component={Profile.Upvotes} /> <Route path="submitted" component={Profile.Submitted} /> <Route path="topics" component={Profile.Topics} /> </Route> </Route> );
  98. export default function Main({ children }: { children: any })

    { return ( <div> <MobileAppBanner /> <Header /> <Player /> <Popover /> <Modal /> <FlashMessage /> <SearchOverlay /> {children} </div> ); }
  99. None
  100. None
  101. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  102. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  103. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  104. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  105. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  106. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  107. <Profile> <Header /> <div> <Navigation /> {children} <Sidebar /> </div>

    </Profile>
  108. export function Profile({ children, user, components, topics }: Pro return

    ( <ConstraintWidth className={styles.container}> <Header user={user} /> <div className={styles.body}> <Navigation user={user} /> <div styles={styles.main}> {children} </div> <Sidebar comments={comments} topics={topics} /> </div> </ConstraintWidth> ); } export default createContainer({ renderComponent: Profile, decorators: [ renderLoading(), renderNotFound((user) => !!user), ], fetch({ dispatch, getState, params: { username } }) { return dispatch(fetchProfile(username));
  109. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  110. None
  111. { isLoading isLoaded resourceId/resourceIds ...otherValues }

  112. { isLoading isLoaded postId postSlug }

  113. LOADED RESET LOADING UPDATE

  114. // Actions export const RESET = 'PAGES/RESET'; export const LOADING

    = 'PAGES/LOADING'; export const LOADED = 'PAGES/LOADED'; export const UPDATE = 'PAGES/UPDATE'; // Pages export const POST = 'PAGES/POST'; const PAGES = { [POST]: { isLoading: false, isLoaded: false, postId: [], postSlug: null, }, // ... other pages }; // ... other pages const initialState = new Immutable.Map(PAGES);
  115. export default createContainer({ renderComponent: Post, decorators: [ renderLoading(), documentTitle(({ post

    }) => post.title), ], fetch({ dispatch, params: { slug } }) { return dispatch(fetchPostPage(slug)); }, mapStateToProps({ pages, posts }) { const page = getPage(page, POST); return { ...page, post: postById(posts, page.postId), }; }, });
  116. export function fetchPostPage(slug: string): Dispatchable { return async (dispatch, getState,

    api) => { const page = getPage(getState().pages, POST); if (page.postSlug === slug) { return; } dispatch(reset(POST, { isLoading: true, postSlug: slug })); const { body } = await api.frontend.posts.findOne({id: slug}); dispatch(bootstrap(body)); dispatch(loaded(POST, { postId: body.post.id, })); }; }
  117. components/ layouts/ lib/ modules/ pages/ styles/ types/ utils/ api.js config.js

    entry.js paths.js reducers.js routes.js
  118. None
  119. https://github.com/ReactTraining/react-router https://github.com/airbnb/enzyme/ https://github.com/chaijs/chai https://github.com/css-modules/css-modules https://github.com/erikras/ducks-modular-redux https://github.com/eslint/eslint https://github.com/facebook/flow https://github.com/gaearon/redux-thunk https://github.com/jnunemaker/flipper https://github.com/producthunt/chai-enzyme

    https://github.com/producthunt/duxy
  120. https://speakerdeck.com/rstankov/react-at-product-hunt

  121. https://www.meetup.com/React-Sofia/

  122. Thanks

  123. None