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. 3.
  2. 4.
  3. 8.
  4. 20.

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

    string, description?: string, followers_count: number, posts_count: number, slug: string, };
  5. 22.

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

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);
  6. 23.

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

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);
  7. 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);
  8. 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);
  9. 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);
  10. 27.

    function isFollowing( user: { followedTopicsIds: Array<number> }, topic: { id:

    number } ): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; }
  11. 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" />
  12. 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" />
  13. 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() { /* ... */ } }
  14. 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 });
  15. 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
  16. 37.

    import api from 'ph/api';
 
 try { // POST /topics/1/followers

    const response = await api.topics.followers.create({ topicId: 1 }); } catch(e) { // handle errors }
  17. 38.

    ⚽ resources - findAll / findOne / create / update

    / delete resource - findAll / create / update / delete get ⚾ post patch delete namespace adapters Duxy
  18. 41.
  19. 42.
  20. 43.
  21. 51.

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

    ( <ol className={styles.list}> {topics.map((topic) => ( <li key={topic.id}> <TopicItem topic={topic} /> </li> ))} </ol> ); }
  22. 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>
  23. 53.

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

    ( <ol className={styles.list}> {topics.map((topic) => ( <li key={topic.id}> <TopicItem topic={topic} /> </li> ))} </ol> ); }
  24. 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}); }); });
  25. 56.
  26. 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> ); }
  27. 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> ); }
  28. 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 = () => {
  29. 69.

    export createContainer({ renderComponent: Component, clientFetch() { // Browser data load

    }, });
 
 class Component extends React.Component { componentDidMount() { clientFetch(); } }
  30. 70.

    export createContainer({ renderComponent: Component, clientFetch(dispatch, getState, api): Promise { //

    Browser data load }, fetch(dispatch, getState, api): Promise { // Browser/Server data load }, });
  31. 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 = () => {
  32. 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 }));
  33. 75.
  34. 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)) {
  35. 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 } }); } }; }
  36. 86.

    function action() { return (dispatch, getState) => { // do

    stuff dispatch({ type: ACTION_1 }); // do stuff dispatch({ type: ACTION_2 }); // ...so on }; } redux-thunk
  37. 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); }
  38. 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 } }); } }; }
  39. 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] });
  40. 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> );
  41. 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 }`; }, }, // ... };
  42. 95.

    import paths from 'ph/paths';
 
 paths.pages.about(); // => /about/ paths.topics.index();

    // => /topics/ paths.topics.show(topic); // => /topics/developer-tools
  43. 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> );
  44. 98.

    export default function Main({ children }: { children: any })

    { return ( <div> <MobileAppBanner /> <Header /> <Player /> <Popover /> <Modal /> <FlashMessage /> <SearchOverlay /> {children} </div> ); }
  45. 99.
  46. 100.
  47. 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));
  48. 110.
  49. 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);
  50. 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), }; }, });
  51. 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, })); }; }
  52. 118.
  53. 122.
  54. 123.