Share
Embed
React Architecture at Product Hunt
What is the architecture at our latest product YourStack.
Radoslav Stankov
September 12, 2020
Transcript
176 slides
-
# Have good defaults $ Have good code organization %
Make common operations easy & Isolate dependencies ' Extensibility and reusability -
# Have good defaults $ Have good code organization %
Make common operations easy & Isolate dependencies ' Extensibility and reusability -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
1 Support components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/
styles/ types/ utils/ config.ts paths.ts -
1 Support Components 2 components/ graphql/ hooks/ layouts/ pages/ routes/
server/ static/ styles/ types/ utils/ config.ts paths.ts -
1 Support Components Pages 2 3 components/ graphql/ hooks/ layouts/
pages/ routes/ server/ static/ styles/ types/ utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
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 -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
apollo client:codegen \ --localSchemaFile="graphql/schema.json" \ --addTypename \ --tagName=gql \ --target=typescript
\ --includes="{components,routes,utils,hooks,layouts}/**/*.{tsx,ts}" \ --outputFlat="graphql/types.ts" -
components/Profile/Avatar/Fragment.ts import gql from 'graphql-tag'; export default gql` fragment ProfileAvatarFragment
on Profile { id name kind imageUrl } `; -
// ==================================================== // GraphQL fragment: ProfileAvatarFragment // ==================================================== export interface
ProfileAvatarFragment { __typename: "Profile"; id: string; name: string; kind: string; imageUrl: string | null; } graphql/types.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
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 -
import { format } from 'date-fns'; export function formatDateTime(date: string)
{ return format(date, 'H:mm A · MMM D, YYYY'); } utils/date.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
( Component as directory components/ Component/ SubComponent/ Fragment.graphql Mutation.graphql icon.svg
index.js styles.css utils.js -
import * as React from "react"; import Font from "components/Font";
<Font.Text>{text}</Font.Text> -
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 -
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> -
import * as React from "react"; import Font from "components/Font";
<Font.Text>text</Font.Text> // -> <span class="text">text</span> -
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> -
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> -
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 -
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 -
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 -
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 -
import * as React from "react"; import Font from "components/Font";
<Font.Text>text</Font.Text> // -> <span class="text">Text</span> -
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> -
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 -
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 -
<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> -
remoteCall Server { result: 'ok' } { errors: {
field1: 'error1', field2: 'error2' } } -
mutation UserSettingsUpdate($input: UserSettingsUpdateInp response: userSettingsUpdate(input: $input) { node { id
...MySettingsPageViewer } errors { field message } } } -
mutation UserSettingsUpdate($input: UserSettingsUpdateInp response: userSettingsUpdate(input: $input) { node { id
...MySettingsPageViewer } errors { field message } } } -
<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> -
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); -
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); -
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); -
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); -
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); -
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); -
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> ); } -
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> ); } -
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; -
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; -
, unified styles - common interface . custom inputs /
understand backend GraphQL 0 Form -
<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} /> -
<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} /> -
<Button.Bordered {...props} /> <Button.Solid {...props} /> <Button.White {...props} /> <Button.Text
{...props} /> <Button.Icon {...props} /> -
<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 -
Domain Component <AnswerCard> <Box> <Card /> <Font.Title /> <Flex.Grid> <Card
/> </Flex> <LikeButton /> <DateFormat /> </Box> </AnswerCard> -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
export default { root: () => '/', static: { about()
=> '/about', // ... } profiles: { people: () => '/people', show: ({ slug }: { slug: string }) => `/@${slug}`, // ... }, // ... }; path.ts -
import paths from 'ph/paths'; paths.root(); // => / paths.static.about();
// => /about/ paths.profiles.people(); // => /people paths.profiles.show(profile); // => /@rstankov -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
<Layout> <Header profile={profile} /> <ContentWithMobileMenu> {children} </ContentWithMobileMenu> <Footer /> <CookiePolicyBanner
/> </Layout> layouts/Main/index.tsx -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
) components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/
types/ utils/ config.ts paths.ts -
components/ graphql/ hooks/ layouts/ pages/ routes/ server/ static/ styles/ types/
utils/ config.ts paths.ts -
Loading State Not Found Error Server Error Authorization Error Authentication
Error Error State Page Life Cycle -
Loading State Not Found Error Server Error Authorization Error Authentication
Error Error State Loaded State Page Life Cycle -
Loading State Not Found Error Server Error Authorization Error Authentication
Error SEO Error State Analytics Loaded State Page Life Cycle ??? -
Loading State Not Found Error Server Error Authorization Error Authentication
Error SEO Error State Analytics Loaded State render Page Life Cycle ??? -
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> ), }); -
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> ), }); -
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> ), }); -
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> ), }); -
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> ), }); -
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> ), }); -
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 -
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) { -
} } tips(first: 1) { edges { node { id
...TipCardFragment } } } peopleWithSimilarInterests(limit: 4) { edges { node { id ...ProfilePeopleSectionFragment } } } ...HeadTagsFragment ...ProfileLayoutFragment ...ProfilesShowDoYouUseFragment ...ProfilesShowDoYouUseFragment } } ${AnswerCardWithQuestionFragment} ${DoYouUseFragment} ${HeadTagsFragment} ${ProfileAvatarLinkFragment} -
. GraphQL # Components 4 isolating dependancies ' directory as
folder / domain components , Pages - paths helper & layouts % createPage 5 Recap