Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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.

Radoslav Stankov

November 19, 2016
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

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

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

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

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);
  4. 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);
  5. 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);
  6. 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);
  7. function isFollowing( user: { followedTopicsIds: Array<number> }, topic: { id:

    number } ): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; }
  8. <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" />
  9. <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" />
  10. <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() { /* ... */ } }
  11. 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 });
  12. 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
  13. import api from 'ph/api';
 
 try { // POST /topics/1/followers

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

    / delete resource - findAll / create / update / delete get ⚾ post patch delete namespace adapters Duxy
  15. function TopicItemList({ topics }: { topics: Array<Topic> }) { return

    ( <ol className={styles.list}> {topics.map((topic) => ( <li key={topic.id}> <TopicItem topic={topic} /> </li> ))} </ol> ); }
  16. 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>
  17. function TopicItemList({ topics }: { topics: Array<Topic> }) { return

    ( <ol className={styles.list}> {topics.map((topic) => ( <li key={topic.id}> <TopicItem topic={topic} /> </li> ))} </ol> ); }
  18. 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}); }); });
  19. 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> ); }
  20. 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> ); }
  21. 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 = () => {
  22. export createContainer({ renderComponent: Component, clientFetch() { // Browser data load

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

    Browser data load }, fetch(dispatch, getState, api): Promise { // Browser/Server data load }, });
  24. 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 = () => {
  25. 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 }));
  26. // 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)) {
  27. 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 } }); } }; }
  28. function action() { return (dispatch, getState) => { // do

    stuff dispatch({ type: ACTION_1 }); // do stuff dispatch({ type: ACTION_2 }); // ...so on }; } redux-thunk
  29. 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); }
  30. 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 } }); } }; }
  31. 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] });
  32. // 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> );
  33. // paths.js export default { root(): string { return '/';

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

    // => /topics/ paths.topics.show(topic); // => /topics/developer-tools
  35. // 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> );
  36. export default function Main({ children }: { children: any })

    { return ( <div> <MobileAppBanner /> <Header /> <Player /> <Popover /> <Modal /> <FlashMessage /> <SearchOverlay /> {children} </div> ); }
  37. 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));
  38. // 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);
  39. 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), }; }, });
  40. 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, })); }; }