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

React Architecture at Product Hunt

Radoslav Stankov
September 12, 2020
770

React Architecture at Product Hunt

What is the architecture at our latest product YourStack.

Radoslav Stankov

September 12, 2020
Tweet

Transcript

  1. !

  2. # Have good defaults $ Have good code organization %

    Make common operations easy & Isolate dependencies ' Extensibility and reusability
  3. # Have good defaults $ Have good code organization %

    Make common operations easy & Isolate dependencies ' Extensibility and reusability
  4. 1 Support Components 2 components/ graphql/ hooks/ layouts/ pages/ routes/

    server/ static/ styles/ types/ utils/ config.ts paths.ts
  5. 1 Support Components Pages 2 3 components/ graphql/ hooks/ layouts/

    pages/ routes/ server/ static/ styles/ types/ utils/ config.ts paths.ts
  6. import getNextConfig from 'next/config'; const config = getNextConfig() as any;

    // ... other configuration export const environment = { isTest: config.publicRuntimeConfig.NODE_ENV === 'test', isProduction: config.publicRuntimeConfig.NODE_ENV === 'production', isBrowser: !!process.browser, }; config.ts
  7. apollo client:codegen \ --localSchemaFile="graphql/schema.json" \ --addTypename \ --tagName=gql \ --target=typescript

    \ --includes="{components,routes,utils,hooks,layouts}/**/*.{tsx,ts}" \ --outputFlat="graphql/types.ts"
  8. // ==================================================== // GraphQL fragment: ProfileAvatarFragment // ==================================================== export interface

    ProfileAvatarFragment { __typename: "Profile"; id: string; name: string; kind: string; imageUrl: string | null; } graphql/types.ts
  9. import formatCount from './formatCount'; describe(formatCount.name, () => { it('does proper

    pluralization', () => { expect(formatCount(0, 'item')).toEqual('0 items'); expect(formatCount(1, 'item')).toEqual('1 item'); expect(formatCount(10, 'items')).toEqual('10 items'); }); it('formats 1000-9999 as Ks', () => { expect(formatCount(1000)).toEqual('1K'); expect(formatCount(1500)).toEqual('1.5K'); }); // .... }); utils/formatCount.test.ts
  10. import { format } from 'date-fns'; export function formatDateTime(date: string)

    { return format(date, 'H:mm A · MMM D, YYYY'); } utils/date.ts
  11. import * as React from "react"; import styles from "./styles.css";

    export function Text({ children }) { return ( <span className={styles.text}> {children} </span> ); } components/Font/index.tsx
  12. CSS // style.css .text { font-size: 14px; } CSS //

    component.js
 import styles from './styles.css'; <span className={styles.text}> </span> CSS // style.css .text_3mRwv { font-size: 14px; } CSS // result.js <span class="text_3mRwv"> </span>

  13. import * as React from "react"; import Font from "components/Font";

    <Font.Text>text</Font.Text> // -> <span class="text">text</span>
  14. import * as React from "react"; import Font from "components/Font";

    <Font.Text>text</Font.Text> // -> <span class="text">text</span> <Font.Text component="p">text</Font.Text> // -> <p class="text">text</p>
  15. import * as React from "react"; import Font from "components/Font";

    <Font.Text>text</Font.Text> // -> <span class="text">text</span> <Font.Text component="p">text</Font.Text> // -> <p class="text">text</p> <Font.Text component={Link} to="/page">text</ Font.Text> // -> <a class="text" href="/page">text</a>
  16. import * as React from "react"; import Font from "components/Font";

    <Font.Text>text</Font.Text> // -> <span class="text">text</span> <Font.Text component="p">text</Font.Text> // -> <p class="text">text</p> <Font.Text component={Link} to="/page">text</ Font.Text> // -> <a class="text" href="/page">text</a> ( Pass custom component as prop
  17. import * as React from "react"; import styles from "./styles.css";

    export function Text({ component, children, ...props }) { Component = component || "span";
 return ( <Component className={styles.text} {...props}> {children} </Component> ); } ( Pass custom component as prop
  18. import * as React from "react"; import styles from "./styles.css";

    export function Text({ component, children, ...props }) { Component = component || "span";
 return ( <Component className={styles.text} {...props}> {children} </Component> ); } ) ( Pass custom component as prop
  19. import * as React from "react"; import styles from "./styles.css";

    export function Text({ component, ...props }) { Component = component || "span"; return <Component className={styles.text} {...props} />; } * ( Pass custom component as prop
  20. import * as React from "react"; import Font from "components/Font";

    <Font.Text>text</Font.Text> // -> <span class="text">Text</span>
  21. import * as React from "react"; import Font from "components/Font";

    import styles from "./styles.css"; <Font.Text className={styles.custom}>text</Font.Text> // -> <span class="text custom">text</span>
  22. import * as React from "react"; import Font from "components/Font";

    import styles from "./styles.css"; <Font.Text className={styles.custom}>text</Font.Text> // -> <span class="text custom">text</span> ( Pass extra class name
  23. import * as React from "react"; import styles from "./styles.css";

    import classNames from "classnames"; export function Text({ component, className, ...props }) { Component = component || "span";
 return <Component className={className(styles.text, className) } ( Pass extra class name
  24. <Form.Mutation mutation={MUTATION} onSubmit={onComplete}> <Form.Field name="title" /> <Form.Field name="email" control="email" />

    <Form.Field name="tagline" control="textarea" /> <Form.Field name="category" control="select" options={CATEGORIES} /> <Form.Field name="photos" control={PhotosInput} />
 <Form.Field name="topics" control={TopicsInput} /> <Form.Submit /> </Form.Mutation>
  25. remoteCall Server { result: 'ok' } { 
 errors: {

    field1: 'error1',
 field2: 'error2'
 }
 }
  26. <Form.Mutation mutation={MUTATION} onSubmit={onComplete}> <Form.Field name="title" /> <Form.Field name="email" control="email" />

    <Form.Field name="tagline" control="textarea" /> <Form.Field name="category" control="select" options={CATEGORIES} /> <Form.Field name="photos" control={PhotosInput} />
 <Form.Field name="topics" control={TopicsInput} /> <Form.Submit /> </Form.Mutation>
  27. import { TopicSearch_Topic as ITopic } from '~/graphql/types'; import ItemList

    from './ItemList' import QUERY from './Query'; import SearchResultItem from './SearchResultItem' import { IFormArrayFields } from '~/components/Form/types'; import { useLoadTopicsIds } from './utils'; interface IProps { fields: IFormArrayFields<string>; } export default function TopicsInput({ fields }: IProps) { const addTopic = (topic: ITopic) => { fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields);
  28. import { TopicSearch_Topic as ITopic } from '~/graphql/types'; import ItemList

    from './ItemList' import QUERY from './Query'; import SearchResultItem from './SearchResultItem' import { IFormArrayFields } from '~/components/Form/types'; import { useLoadTopicsIds } from './utils'; interface IProps { fields: IFormArrayFields<string>; } export default function TopicsInput({ fields }: IProps) { const addTopic = (topic: ITopic) => { fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields);
  29. import { TopicSearch_Topic as ITopic } from '~/graphql/types'; import ItemList

    from './ItemList' import QUERY from './Query'; import SearchResultItem from './SearchResultItem' import { IFormArrayFields } from '~/components/Form/types'; import { useLoadTopicsIds } from './utils'; interface IProps { fields: IFormArrayFields<string>; } export default function TopicsInput({ fields }: IProps) { const addTopic = (topic: ITopic) => { fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields);
  30. import { TopicSearch_Topic as ITopic } from '~/graphql/types'; import ItemList

    from './ItemList' import QUERY from './Query'; import SearchResultItem from './SearchResultItem' import { IFormArrayFields } from '~/components/Form/types'; import { useLoadTopicsIds } from './utils'; interface IProps { fields: IFormArrayFields<string>; } export default function TopicsInput({ fields }: IProps) { const addTopic = (topic: ITopic) => { fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields);
  31. import { TopicSearch_Topic as ITopic } from '~/graphql/types'; import ItemList

    from './ItemList' import QUERY from './Query'; import SearchResultItem from './SearchResultItem' import { IFormArrayFields } from '~/components/Form/types'; import { useLoadTopicsIds } from './utils'; interface IProps { fields: IFormArrayFields<string>; } export default function TopicsInput({ fields }: IProps) { const addTopic = (topic: ITopic) => { fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields);
  32. import { TopicSearch_Topic as ITopic } from '~/graphql/types'; import ItemList

    from './ItemList' import QUERY from './Query'; import SearchResultItem from './SearchResultItem' import { IFormArrayFields } from '~/components/Form/types'; import { useLoadTopicsIds } from './utils'; interface IProps { fields: IFormArrayFields<string>; } export default function TopicsInput({ fields }: IProps) { const addTopic = (topic: ITopic) => { fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields);
  33. fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) =>

    { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields); if (topics === null) { return null; } return ( <React.Fragment> <SearchInput itemsPath="topics" onSelect={addTopic} query={QUERY} renderItem={SearchResultItem} /> <ItemList topics={topics} removeTopic={removeTopic} /> </React.Fragment> ); }
  34. fields.value.includes(topic.id) && fields.push(topic.id); } const removeTopic = (topic: ITopic) =>

    { fields.remove(fields.value.indexOf(topic.id)); } const topics = useLoadTopicsIds(fields); if (topics === null) { return null; } return ( <React.Fragment> <SearchInput itemsPath="topics" onSelect={addTopic} query={QUERY} renderItem={SearchResultItem} /> <ItemList topics={topics} removeTopic={removeTopic} /> </React.Fragment> ); }
  35. const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const

    topics = useLoadTopicsIds(fields); if (topics === null) { return null; } return ( <React.Fragment> <SearchInput itemsPath="topics" onSelect={addTopic} query={QUERY} renderItem={SearchResultItem} /> <ItemList topics={topics} removeTopic={removeTopic} /> </React.Fragment> ); } ProductsInput.isArray = true;
  36. const removeTopic = (topic: ITopic) => { fields.remove(fields.value.indexOf(topic.id)); } const

    topics = useLoadTopicsIds(fields); if (topics === null) { return null; } return ( <React.Fragment> <SearchInput itemsPath="topics" onSelect={addTopic} query={QUERY} renderItem={SearchResultItem} /> <ItemList topics={topics} removeTopic={removeTopic} /> </React.Fragment> ); } ProductsInput.isArray = true;
  37. +

  38. +

  39. +

  40. , unified styles - common interface . custom inputs /

    understand backend GraphQL 0 Form
  41. <Button href={paths.profile(profile)} /> <Button onClick={onClickReturnsPromise} loadingText="Waiting for promise to resolve"

    /> <Button confirm="Are you sure?" mutation={DELETE_MUTATION} input={{ id }} loadingText="Deleting..." onMutate={redirectSomeWhere} />
  42. <Button href={paths.profile(profile)} /> <Button onClick={onClickReturnsPromise} loadingText="Waiting for promise to resolve"

    /> <Button confirm="Are you sure?" mutation={DELETE_MUTATION} input={{ id }} loadingText="Deleting..." onMutate={redirectSomeWhere} />
  43. <AnswerCard>
 <Box> <Card thumbnail={<ProfileAvatar profile={answer.profile}} name={answer.profile.name} subtitle="recommendations for" /> <Font.Title>{answer.question.title}</Font.Title>

    <Flex.Grid> {answer.products.map((product) => ( <Card url={paths.profile.show(product)} thumbnail={<ProfileAvatar profile={product}} name={product.name} /> ))} </Flex> <LikeButton subject={answer} /> <DateFormat date={answer.createdAt} /> </Box> </AnswerCard> Domain Component
  44. Domain Component <AnswerCard> <Box>
 <Card /> <Font.Title /> <Flex.Grid> <Card

    /> </Flex> <LikeButton /> <DateFormat />
 </Box> </AnswerCard>
  45. export default { root: () => '/', static: { about()

    => '/about', // ... } profiles: { people: () => '/people', show: ({ slug }: { slug: string }) => `/@${slug}`, // ... }, // ... }; path.ts
  46. import paths from 'ph/paths'; 
 paths.root(); // => / paths.static.about();

    // => /about/ paths.profiles.people(); // => /people paths.profiles.show(profile); // => /@rstankov
  47. Loading State Not Found Error Server Error Authorization Error Authentication

    Error SEO Error State Analytics Loaded State Page Life Cycle ???
  48. Loading State Not Found Error Server Error Authorization Error Authentication

    Error SEO Error State Analytics Loaded State render Page Life Cycle ???
  49. import createPage from '~/utils/createPage'; import ProfileLayout from '~/layouts/Profile'; import {

    ProfileShowPage } from '~/graphql/types'; import QUERY from './Query'; export default createPage<ProfileShowPage>({ query: QUERY, queryVariables: ({ slug }) => ({ slug }), requireLogin: true, requirePermissions: ({ profile }) => profile.canManage, requireFeature: 'feature_flag', tags: ({ profile }) => profile.seoTags, title: ({ profile }) => profile.name, component: ({ data: { profile } }) => ( <ProfileLayout profile={profile}>{...}</ProfileLayout> ), });
  50. import createPage from '~/utils/createPage'; import ProfileLayout from '~/layouts/Profile'; import {

    ProfileShowPage } from '~/graphql/types'; import QUERY from './Query'; export default createPage<ProfileShowPage>({ query: QUERY, queryVariables: ({ slug }) => ({ slug }), requireLogin: true, requirePermissions: ({ profile }) => profile.canManage, requireFeature: 'feature_flag', tags: ({ profile }) => profile.seoTags, title: ({ profile }) => profile.name, component: ({ data: { profile } }) => ( <ProfileLayout profile={profile}>{...}</ProfileLayout> ), });
  51. import createPage from '~/utils/createPage'; import ProfileLayout from '~/layouts/Profile'; import {

    ProfileShowPage } from '~/graphql/types'; import QUERY from './Query'; export default createPage<ProfileShowPage>({ query: QUERY, queryVariables: ({ slug }) => ({ slug }), requireLogin: true, requirePermissions: ({ profile }) => profile.canManage, requireFeature: 'feature_flag', tags: ({ profile }) => profile.seoTags, title: ({ profile }) => profile.name, component: ({ data: { profile } }) => ( <ProfileLayout profile={profile}>{...}</ProfileLayout> ), });
  52. import createPage from '~/utils/createPage'; import ProfileLayout from '~/layouts/Profile'; import {

    ProfileShowPage } from '~/graphql/types'; import QUERY from './Query'; export default createPage<ProfileShowPage>({ query: QUERY, queryVariables: ({ slug }) => ({ slug }), requireLogin: true, requirePermissions: ({ profile }) => profile.canManage, requireFeature: 'feature_flag', tags: ({ profile }) => profile.seoTags, title: ({ profile }) => profile.name, component: ({ data: { profile } }) => ( <ProfileLayout profile={profile}>{...}</ProfileLayout> ), });
  53. import createPage from '~/utils/createPage'; import ProfileLayout from '~/layouts/Profile'; import {

    ProfileShowPage } from '~/graphql/types'; import QUERY from './Query'; export default createPage<ProfileShowPage>({ query: QUERY, queryVariables: ({ slug }) => ({ slug }), requireLogin: true, requirePermissions: ({ profile }) => profile.canManage, requireFeature: 'feature_flag', tags: ({ profile }) => profile.seoTags, title: ({ profile }) => profile.name, component: ({ data: { profile } }) => ( <ProfileLayout profile={profile}>{...}</ProfileLayout> ), });
  54. import createPage from '~/utils/createPage'; import ProfileLayout from '~/layouts/Profile'; import {

    ProfileShowPage } from '~/graphql/types'; import QUERY from './Query'; export default createPage<ProfileShowPage>({ query: QUERY, queryVariables: ({ slug }) => ({ slug }), requireLogin: true, requirePermissions: ({ profile }) => profile.canManage, requireFeature: 'feature_flag', tags: ({ profile }) => profile.seoTags, title: ({ profile }) => profile.name, component: ({ data: { profile } }) => ( <ProfileLayout profile={profile}>{...}</ProfileLayout> ), });
  55. import gql from 'graphql-tag'; import AnswerCardWithQuestionFragment from '~/components/Answer/Card/FragmentWithQue import DoYouUseFragment

    from './DoYouUse/Fragment'; import HeadTagsFragment from '~/utils/createPage/HeadTagsFragment'; import ProfileAvatarLinkFragment from '~/components/Profile/AvatarLink/Fragment'; import ProfileLayoutFragment from '~/layouts/Profile/Fragment'; import ProfilePeopleSectionFragment from '~/components/Profile/PeopleSection/Fragmen import RecommendedProductsFragment from './RecommendedProducts/Fragment'; import StackItemProfileFragment from '~/components/Stack/Item/Fragment'; import TipCardFragment from '~/components/Tip/Card/Fragment'; export default gql` query ProfilesShowPage($slug: String!) { profile(slug: $slug) { id answersCount canManage likedAnswersCount questionsCount tipsCount whitelisted using(first: 8) { edges { node { id note
  56. import StackItemProfileFragment from '~/components/Stack/Item/Fragment'; import TipCardFragment from '~/components/Tip/Card/Fragment'; export default

    gql` query ProfilesShowPage($slug: String!) { profile(slug: $slug) { id answersCount canManage likedAnswersCount questionsCount tipsCount whitelisted using(first: 8) { edges { node { id note profileTo { id name ...ProfileAvatarLinkFragment ...StackItemProfileFragment } } } } answers(first: 3) {
  57. } } tips(first: 1) { edges { node { id

    ...TipCardFragment } } } peopleWithSimilarInterests(limit: 4) { edges { node { id ...ProfilePeopleSectionFragment } } } ...HeadTagsFragment ...ProfileLayoutFragment ...ProfilesShowDoYouUseFragment ...ProfilesShowDoYouUseFragment } } ${AnswerCardWithQuestionFragment} ${DoYouUseFragment} ${HeadTagsFragment} ${ProfileAvatarLinkFragment}
  58. . GraphQL # Components 4 isolating dependancies ' directory as

    folder / domain components , Pages - paths helper & layouts % createPage 5 Recap