Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

React Architecture in Product Hunt

Radoslav Stankov
September 24, 2019

React Architecture in Product Hunt

Talk was given at React Alicante 2019 (https://reactalicante.es)

Video of the talk 👉 https://www.youtube.com/watch?v=bo0u9402OXU

Radoslav Stankov

September 24, 2019
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. & Have good defaults ' Have good code organization (

    Make common operations easy ) Isolate dependencies * Extensibility and reusability
  2. & Have good defaults ' Have good code organization (

    Make common operations easy ) Isolate dependencies * Extensibility and reusability
  3. 1 Support Components components/ graphql/ hooks/ layouts/ pages/ routes/ server/

    static/ styles/ types/ utils/ config.ts paths.ts 2
  4. 1 Support Components Pages components/ graphql/ hooks/ layouts/ pages/ routes/

    server/ static/ styles/ types/ utils/ config.ts paths.ts 2 3
  5. 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

  6. apollo client:codegen \ --localSchemaFile="graphql/schema.json" \ --addTypename \ --tagName=gql \ --target=typescript

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

    ProfileAvatarFragment { __typename: "Profile"; id: string; name: string; kind: string; imageUrl: string | null; } 
 graphql/types.ts

  8. 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

  9. import { format } from 'date-fns'; export function formatDateTime(date: string)

    { return format(date, 'H:mm A · MMM D, YYYY'); } 
 utils/date.ts

  10. 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

  11. 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>

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

    <Font.Text>text</Font.Text> // -> <span class="text">text</span>
  13. 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>
  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> <Font.Text component={Link} to="/page">text</Font.Text> // -> <a class="text" href="/page">text</a>
  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> 
 + Pass custom component as prop 

  16. 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 

  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, ...props }) { Component = component || "span"; return <Component className={styles.text} {...props} />; } , 
 + Pass custom component as prop 

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

    <Font.Text>text</Font.Text> // -> <span class="text">Text</span>
  20. 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>
  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> 
 + Pass extra class name 

  22. 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)} {...props} />; } 
 + Pass extra class name 

  23. <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>
  24. remoteCall Server { result: 'ok' } { 
 errors: {

    field1: 'error1',
 field2: 'error2'
 }
 }
  25. <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>
  26. 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);
  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. 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;
  33. 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;
  34. 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;
  35. 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. -

  37. -

  38. -

  39. . unified styles / common interface 0 custom inputs
 1

    understand backend GraphQL 
 2 Form 

  40. <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} />
  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. <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

  43. 
 Domain Component
 <AnswerCard> <Box>
 <Card /> <Font.Title /> <Flex.Grid>

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

    => '/about', // ... } profiles: { people: () => '/people', show: ({ slug }: { slug: string }) => `/@${slug}`, // ... }, // ... }; 
 path.ts

  45. import paths from 'ph/paths'; 
 paths.root(); // => / paths.static.about();

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

    Error Error State Loaded State 
 Page Life Cycle

  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/FragmentWithQuestion'; 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/Fragment'; 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 {
  56. 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. peopleWithSimilarInterests(limit: 4) { edges { node { id ...ProfilePeopleSectionFragment }

    } } ...HeadTagsFragment ...ProfileLayoutFragment ...ProfilesShowDoYouUseFragment ...ProfilesShowDoYouUseFragment } } ${AnswerCardWithQuestionFragment} ${DoYouUseFragment} ${HeadTagsFragment} ${ProfileAvatarLinkFragment} ${ProfileLayoutFragment} ${ProfilePeopleSectionFragment} ${RecommendedProductsFragment} ${StackItemProfileFragment} ${TipCardFragment} `;
  58. 0 GraphQL & Components 6 isolating dependancies * directory as

    folder
 1 domain components . Pages
 / paths helper ) layouts
 ( createPage 
 7 Recap