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.
React at Product HuntRadoslav Stankov 19/11/2016
View Slide
Radoslav Stankov@rstankovhttp://rstankov.comhttp://github.com/rstankov
Developer Team
Tech Stack
Tech Stack ES2016 BABEL ESLINT FLOW
early 2014 jQuery spaghettiOctober 2014 BackboneFebruary 2015 React & RailsMay 2015 custom Flux
December 2015 ReduxJanuary 2016 React-RouterApril 2016 Redux DucksOctober 2016 JSTalks talk :)
components/layouts/lib/modules/pages/styles/types/utils/api.jsconfig.jsentry.jspaths.jsreducers.jsroutes.js
https://github.com/facebook/flow
export type Topic = {id: number,image_uuid?: ?string,name: string,description?: string,followers_count: number,posts_count: number,slug: string,};
function isFollowing(user: CurrentUser, topic: Topic): boolean {return user.followedTopicsIds.indexOf(topic.id) !== -1;}
function isFollowing(user: CurrentUser, topic: Topic): boolean {return user.followedTopicsIds.indexOf(topic.id) !== -1;}isFollowing(null, topic);isFollowing(user, null);isFollowing(user, somethingElse);
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);
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);
function isFollowing(user: { followedTopicsIds: Array },topic: { id: number }): boolean {return user.followedTopicsIds.indexOf(topic.id) !== -1;}
class UserImage extends React.Component {props:{| user: User, width: number, height: number |} |{| user: User, variant: 'big' | 'medium' | 'small' |};render() { /* ... */ }}
https://github.com/producthunt/duxy
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});
import api from 'ph/api';const response = await api.context(); // GET /contextconst response = await api.topics.findOne({ id: 1 }) // GET /topics/1const response = await api.topics.findAll({ limit: 10 }) // GET /topics?limit=10
import api from 'ph/api'; try {// POST /topics/1/followersconst response = await api.topics.followers.create({ topicId: 1 });} catch(e) {// handle errors}
⚽ resources - findAll / findOne / create / update / delete resource - findAll / create / update / delete get⚾ post patch delete namespace adaptersDuxy
function TopicItemList({ topics }: { topics: Array }) {return ({topics.map((topic) => ())});}
CSS// style.css.list {list-style: none;}CSS// component.js import styles from './styles.css';CSS// style.css.list_3mRwv {list-style: none;}CSS// result.js
describe(TopicItemList.name, () => {it('renders list of topics', () => {const topic = new TopicFactory();const wrapper = shallow()expect(wrapper).to.contain(});});
https://github.com/producthunt/chai-enzyme
function TopicItem({ topic }: { topic: Topic }) {return ({topic.name}{followersCount(topic.followers_count)});}
…………………
export class TopicFollowButton extends React.Component {props: {isFollowing: boolean,toggle: typeof toggleTopicFollow,topicId: number,className: string,};render() {return (active={this.props.isFollowing}className={this.props.className}onClick={this.handleClickAction}data-test="follow-button"{this.props.isFollowing ? 'Following' : 'Follow'});}handleClickAction: Function = () => {
export createContainer({renderComponent: Component,actionCreators: actionCreators,mapStateToProps: mapStateToProps,});export connect(mapStateToProps, actionCreators)(Component)
export createContainer({renderComponent: Component,clientFetch() {// Browser data load},}); class Component extends React.Component {componentDidMount() {clientFetch();}}
export createContainer({renderComponent: Component,clientFetch(dispatch, getState, api): Promise {// Browser data load},fetch(dispatch, getState, api): Promise {// Browser/Server data load},});
export createContainer({renderComponent: Component,decorators: [renderNotFound(someCondition),renderLoading(),addBodyClassName('white-background')documentTitle('About page'),requireEnabledFeature('new_topics')// ...]});
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 }));
/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
Ducks: Redux Reducer Bundleshttps://github.com/erikras/ducks-modular-redux
/modules/annotations.js/modules/collections.js/modules/context.js/modules/currentUser.js/modules/pages.js/modules/posts.js/modules/topics.js
modules/[reducer].js1. Actions2. Reducer3. Action Creators4. Selectors5. Other stuff ¯\_(ツ)_/¯
// Actionsexport const FOLLOW_TOPIC = 'TOPICS/FOLLOW';export const RECEIVE_TOPICS = 'TOPICS/RECEIVE';export const UNFOLLOW_TOPIC = 'TOPICS/UNFOLLOW';// Reducertype TopicsState = Immutable.Map;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 Creatorsexport function receiveTopic({ topic }: { topic: Topic }): Dispatchable {return { type: RECEIVE_TOPICS, payload: { topics: [topic] } };}export function receiveTopics({ topics }: { topics: Array }): 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)) {
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 } });}};}
https://github.com/gaearon/redux-thunk
function action() {return {type: ACTION, payload: {},};}redux-thunk
function action() {return (dispatch, getState) => {// do stuffdispatch({ type: ACTION_1 });// do stuffdispatch({ type: ACTION_2 });// ...so on};}redux-thunk
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);}
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 } });}};}
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] });
https://github.com/ReactTraining/react-routerv3
// routes.jsexport default createRoutes();
// paths.jsexport default {root(): string {return '/';},pages: {about(): string {return '/about';},// ...}topics: {index(): string {return '/topics';},show({ slug }: Topic): string {return `/topics/${ slug }`;},},// ...};
import paths from 'ph/paths'; paths.pages.about(); // => /about/paths.topics.index(); // => /topics/paths.topics.show(topic); // => /topics/developer-tools
export default function Main({ children }: { children: any }) {return ({children});}
{children}
export function Profile({ children, user, components, topics }: Proreturn ({children});}export default createContainer({renderComponent: Profile,decorators: [renderLoading(),renderNotFound((user) => !!user),],fetch({ dispatch, getState, params: { username } }) {return dispatch(fetchProfile(username));
{isLoadingisLoadedresourceId/resourceIds...otherValues}
{isLoadingisLoadedpostIdpostSlug}
LOADEDRESETLOADINGUPDATE
// Actionsexport const RESET = 'PAGES/RESET';export const LOADING = 'PAGES/LOADING';export const LOADED = 'PAGES/LOADED';export const UPDATE = 'PAGES/UPDATE';// Pagesexport const POST = 'PAGES/POST';const PAGES = {[POST]: {isLoading: false,isLoaded: false,postId: [],postSlug: null,},// ... other pages};// ... other pagesconst initialState = new Immutable.Map(PAGES);
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),};},});
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,}));};}
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
https://speakerdeck.com/rstankov/react-at-product-hunt
https://www.meetup.com/React-Sofia/
Thanks