Repository: wikibook/react-deep-dive-example Branch: main Commit: 5012f2bd0a21 Files: 394 Total size: 253.5 KB Directory structure: gitextract_dby4eova/ ├── .github/ │ └── workflows/ │ └── build.yaml ├── .gitignore ├── README.md ├── chapter10/ │ └── react-gradual-demo/ │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── public/ │ │ └── index.html │ └── src/ │ ├── index.js │ ├── legacy/ │ │ ├── Greeting.js │ │ ├── README.md │ │ ├── createLegacyRoot.js │ │ └── package.json │ ├── modern/ │ │ ├── AboutPage.js │ │ ├── App.js │ │ ├── HomePage.js │ │ ├── README.md │ │ ├── index.js │ │ ├── lazyLegacyRoot.js │ │ └── package.json │ └── shared/ │ ├── Clock.js │ ├── README.md │ ├── ThemeContext.js │ └── useTime.js ├── chapter11/ │ ├── next13/ │ │ ├── .eslintignore │ │ ├── .eslintrc.js │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .prettierignore │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── hello/ │ │ │ │ │ └── route.ts │ │ │ │ ├── posts/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ └── users/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── [id].ts │ │ │ │ └── route.ts │ │ │ ├── context/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── error/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── grouped-layouts/ │ │ │ │ ├── (main)/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── (todos)/ │ │ │ │ │ ├── hello/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── todos/ │ │ │ │ │ └── page.tsx │ │ │ │ └── (users)/ │ │ │ │ ├── layout.tsx │ │ │ │ └── users/ │ │ │ │ └── page.tsx │ │ │ ├── head/ │ │ │ │ ├── [userId]/ │ │ │ │ │ ├── head.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── sub.tsx │ │ │ │ ├── head.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── head.tsx │ │ │ ├── internal-api/ │ │ │ │ └── hello/ │ │ │ │ └── route.ts │ │ │ ├── isr/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── layouts/ │ │ │ │ ├── [userId]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── loading/ │ │ │ │ ├── [userId]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── server-action/ │ │ │ │ ├── form/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── start-transition/ │ │ │ │ └── [id]/ │ │ │ │ └── page.tsx │ │ │ ├── ssg/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── ssr/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── streaming/ │ │ │ │ ├── [id]/ │ │ │ │ │ ├── components.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── styles/ │ │ │ ├── css-modules/ │ │ │ │ ├── page.tsx │ │ │ │ └── styles.module.css │ │ │ ├── global-css/ │ │ │ │ ├── page.tsx │ │ │ │ └── style.css │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── styled-components/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── styled-jsx/ │ │ │ ├── StyledRegistry.tsx │ │ │ ├── components.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── middleware.ts │ │ ├── next.config.js │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── Counter.tsx │ │ │ │ ├── DefaultHeader.tsx │ │ │ │ ├── ErrorButton.tsx │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── StyledComponentsRegistry.tsx │ │ │ │ ├── Tab.tsx │ │ │ │ ├── TabGroup.tsx │ │ │ │ ├── components.ts │ │ │ │ └── server-action/ │ │ │ │ └── client-component.tsx │ │ │ ├── constant/ │ │ │ │ └── menu.ts │ │ │ ├── context/ │ │ │ │ └── counter.tsx │ │ │ ├── lib/ │ │ │ │ └── utils.ts │ │ │ ├── server-action/ │ │ │ │ └── index.ts │ │ │ └── services/ │ │ │ ├── constant.ts │ │ │ └── server.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── server-components-demo/ │ ├── .gitignore │ ├── .nvmrc │ ├── .prettierignore │ ├── .prettierrc.js │ ├── CODE_OF_CONDUCT.md │ ├── Dockerfile │ ├── LICENSE │ ├── README.md │ ├── credentials.js │ ├── docker-compose.yml │ ├── notes/ │ │ └── .gitkeep │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ └── style.css │ ├── scripts/ │ │ ├── build.js │ │ ├── init_db.sh │ │ └── seed.js │ ├── server/ │ │ ├── api.server.js │ │ └── package.json │ └── src/ │ ├── App.js │ ├── EditButton.js │ ├── Note.js │ ├── NoteEditor.js │ ├── NoteList.js │ ├── NoteListSkeleton.js │ ├── NotePreview.js │ ├── NoteSkeleton.js │ ├── SearchField.js │ ├── SidebarNote.js │ ├── SidebarNoteContent.js │ ├── Spinner.js │ ├── TextWithMarkdown.js │ ├── db.js │ └── framework/ │ ├── bootstrap.js │ └── router.js ├── chapter2/ │ └── react/ │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .npmrc │ ├── .prettierignore │ ├── .prettierrc │ ├── README.md │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ ├── manifest.json │ │ └── robots.txt │ ├── src/ │ │ ├── App.tsx │ │ ├── index.tsx │ │ ├── react-app-env.d.ts │ │ └── routes/ │ │ ├── 2-1.tsx │ │ ├── 2-4.tsx │ │ ├── 2-5.tsx │ │ ├── 2-7.tsx │ │ └── 2-8.tsx │ └── tsconfig.json ├── chapter4/ │ ├── next-example/ │ │ ├── .eslintignore │ │ ├── .eslintrc.js │ │ ├── .gitignore │ │ ├── .prettierignore │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── next.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── pages/ │ │ │ │ ├── 404.tsx │ │ │ │ ├── 500.tsx │ │ │ │ ├── _app.tsx │ │ │ │ ├── _document.tsx │ │ │ │ ├── _error.tsx │ │ │ │ ├── api/ │ │ │ │ │ └── hello.ts │ │ │ │ ├── hello/ │ │ │ │ │ ├── [greeting].tsx │ │ │ │ │ └── world.tsx │ │ │ │ ├── hello.tsx │ │ │ │ ├── hi/ │ │ │ │ │ └── [...props].tsx │ │ │ │ ├── index.tsx │ │ │ │ └── todo/ │ │ │ │ └── [id].tsx │ │ │ └── styles/ │ │ │ ├── Home.module.css │ │ │ └── globals.css │ │ └── tsconfig.json │ └── ssr-example/ │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .prettierignore │ ├── .prettierrc │ ├── README.md │ ├── checkStream.js │ ├── package.json │ ├── public/ │ │ ├── index-end.html │ │ ├── index-front.html │ │ └── index.html │ ├── src/ │ │ ├── components/ │ │ │ ├── App.tsx │ │ │ └── Todo.tsx │ │ ├── fetch/ │ │ │ └── index.ts │ │ ├── index.tsx │ │ └── server.ts │ ├── tsconfig.json │ ├── typings.d.ts │ ├── watch-stream.js │ └── webpack.config.js ├── chapter8/ │ ├── eslint-plugin-yceffort/ │ │ ├── .eslintrc.js │ │ ├── .npmrc │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── docs/ │ │ │ └── rules/ │ │ │ └── no-new-date.md │ │ ├── lib/ │ │ │ ├── index.js │ │ │ └── rules/ │ │ │ └── no-new-date.js │ │ ├── package.json │ │ └── tests/ │ │ └── lib/ │ │ └── rules/ │ │ └── no-new-date.js │ └── react-test/ │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── README.md │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ ├── manifest.json │ │ └── robots.txt │ ├── src/ │ │ ├── App.css │ │ ├── App.test.tsx │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── FetchComponent/ │ │ │ │ ├── index.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── StateComponent/ │ │ │ │ ├── index.test.tsx │ │ │ │ └── index.tsx │ │ │ └── StaticComponent/ │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── hooks/ │ │ │ ├── useEffectDebugger.test.ts │ │ │ └── useEffectDebugger.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── react-app-env.d.ts │ │ ├── reportWebVitals.ts │ │ └── setupTests.ts │ └── tsconfig.json └── chapter9/ ├── danger-react-app/ │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .npmrc │ ├── .prettierignore │ ├── .prettierrc │ ├── README.md │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ ├── manifest.json │ │ └── robots.txt │ └── src/ │ ├── App.css │ ├── App.js │ ├── index.css │ └── index.js ├── deploy/ │ ├── aws/ │ │ ├── cra/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── public/ │ │ │ │ ├── index.html │ │ │ │ ├── manifest.json │ │ │ │ └── robots.txt │ │ │ ├── src/ │ │ │ │ ├── App.css │ │ │ │ ├── App.test.tsx │ │ │ │ ├── App.tsx │ │ │ │ ├── index.css │ │ │ │ ├── index.tsx │ │ │ │ ├── react-app-env.d.ts │ │ │ │ ├── reportWebVitals.ts │ │ │ │ └── setupTests.ts │ │ │ └── tsconfig.json │ │ └── next/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── api/ │ │ │ │ └── hello.ts │ │ │ └── index.tsx │ │ ├── styles/ │ │ │ ├── Home.module.css │ │ │ └── globals.css │ │ └── tsconfig.json │ ├── digitalocean/ │ │ ├── cra/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── public/ │ │ │ │ ├── index.html │ │ │ │ ├── manifest.json │ │ │ │ └── robots.txt │ │ │ └── src/ │ │ │ ├── App.css │ │ │ ├── App.js │ │ │ ├── App.test.js │ │ │ ├── index.css │ │ │ ├── index.js │ │ │ ├── reportWebVitals.js │ │ │ └── setupTests.js │ │ └── next/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── api/ │ │ │ │ └── hello.ts │ │ │ └── index.tsx │ │ ├── styles/ │ │ │ ├── Home.module.css │ │ │ └── globals.css │ │ └── tsconfig.json │ ├── netlify/ │ │ ├── cra/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── public/ │ │ │ │ ├── index.html │ │ │ │ ├── manifest.json │ │ │ │ └── robots.txt │ │ │ ├── src/ │ │ │ │ ├── App.css │ │ │ │ ├── App.test.tsx │ │ │ │ ├── App.tsx │ │ │ │ ├── index.css │ │ │ │ ├── index.tsx │ │ │ │ ├── react-app-env.d.ts │ │ │ │ ├── reportWebVitals.ts │ │ │ │ └── setupTests.ts │ │ │ └── tsconfig.json │ │ └── next/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── netlify.toml │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── api/ │ │ │ │ └── hello.ts │ │ │ ├── hello.tsx │ │ │ └── index.tsx │ │ ├── styles/ │ │ │ ├── Home.module.css │ │ │ └── globals.css │ │ └── tsconfig.json │ └── vercel/ │ ├── cra/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── public/ │ │ │ ├── index.html │ │ │ ├── manifest.json │ │ │ └── robots.txt │ │ ├── src/ │ │ │ ├── App.css │ │ │ ├── App.test.tsx │ │ │ ├── App.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── react-app-env.d.ts │ │ │ ├── reportWebVitals.ts │ │ │ └── setupTests.ts │ │ └── tsconfig.json │ └── next/ │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── pages/ │ │ ├── _app.tsx │ │ ├── api/ │ │ │ └── hello.ts │ │ └── index.tsx │ ├── styles/ │ │ ├── Home.module.css │ │ └── globals.css │ └── tsconfig.json └── zero-to-next/ ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── next.config.js ├── package.json ├── src/ │ ├── _app.tsx │ ├── components/ │ │ ├── common/ │ │ │ └── title.tsx │ │ └── todo/ │ │ └── todo.tsx │ ├── hooks/ │ │ └── useToggle.ts │ ├── pages/ │ │ ├── _document.tsx │ │ ├── index.tsx │ │ └── todos/ │ │ └── [id].tsx │ ├── types/ │ │ └── todo.ts │ └── utils/ │ └── errors/ │ └── index.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yaml ================================================ name: chapter9 build run-name: ${{ github. actor }} has been added new commit. on: push: branches-ignore: - 'main' paths: - ./chapter9/zero-to-next jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 16 - name: 'install dependencies' working-directory: ./chapter9/zero-to-next run: npm ci - name: 'build' working-directory: ./chapter9/zero-to-next run: npm run build ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output .next # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and *not* Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port build/ .vscode .DS_Store .idea ================================================ FILE: README.md ================================================ # react-deep-dive-example 《모던 리액트 Deep Dive》 예제 코드입니다. ## Table of Contents ### 2장 리액트 핵심 요소 깊게 살펴보기 [📁](./chapter2) #### react [📁](./chapter2/react) 2장에서 다룬 리액트와 관련된 리액트 예제를 모아두었습니다. ### chapter4 서버 사이드 렌더링 [📁](./chapter4) #### ssr-example [📁](chapter4/ssr-example) 4-2 장에서 다룬 리액트 서버사이드 렌더링 api를 바탕으로 실제 리액트 api 를 기반으로 바닐라 서버사이드 렌더링 애플리케이션을 만든 예제 애플리케이션입니다. #### next-example [📁](chapter4/next-example) 4-3 장에서 다룬 nextjs 에 대한 예제 애플리케이션입니다. ### chapter8 좋은 리액트 코드 작성을 위한 환경 구축하기 [📁](./chapter8) #### eslint-plugin-yceffort [📁](chapter8/eslint-plugin-yceffort) 8-1 장에서 다룬 사용자 정의 `eslint-plugin`을 만들어본 예제 입니다. `eslint-plugin-yceffort`라는 이름으로 만들어졌습니다. #### react-test [📁](chapter8/react-test) 8-2 장에서 다룬 리액트 테스트 코드관련 예제 코드를 모아두었습니다. ### chapter9 모던 리액트 개발 도구로 개발 및 배포 환경 구축하기 [📁](./chapter9) #### zero-to-next [📁](chapter9/zero-to-next) 9-1장 에서 다룬 빈 폴더에서 부터 nextjs 애플리케이션을 만들어본 예제입니다. #### danger-react-app [📁](chapter9/danger-react-app) 9-2장 에서 다룬 보안 취약점이 있는 리액트 애플리케이션을 수정한 예제 입니다. 주요 보안 이슈를 수정했지만 여전히 잠재적인 보안 취약점이 있을 수 있으므로 사용하는 것을 권장하지 않습니다. #### deploy [📁](chapter9/deploy) 9-3장에서 다뤘던 여러 SaaS 서비스에 배포 해본 예제입니다. ### chapter10 리액트 17과 18의 변경사항 살펴보기 [📁](./chapter10) #### react-gradual-demo [📁](chapter10/react-gradual-demo) 10-1장 리액트 17의 변경에 대해서 다룬 내용 중 하나인 리액트의 점진적인 업데이트를 구현해본 예제 애플리케이션입니다. ### chapter11 Next.js13과 리액트 18 [📁](./chapter11) #### server-components-demo [📁](chapter11/server-components-demo) 11-2 장에서 소개한 서버 컴포넌트에 대한 예제 애플리케이션입니다. nextjs와 같은 프레임워크를 사용하지 않은 순수 리액트 서버 컴포넌트 예제 입니다. #### next13 [📁](chapter11/next13) 11-3 장에서 다룬 next@13 이상 버전에서 제공 되고 있는 app directory 를 활용한 예제 애플리케이션입니다. ================================================ FILE: chapter10/react-gradual-demo/.eslintignore ================================================ node_modules build src/*/shared ================================================ FILE: chapter10/react-gradual-demo/.eslintrc.js ================================================ module.exports = { extends: [ '@titicaca/eslint-config-triple', '@titicaca/eslint-config-triple/frontend', '@titicaca/eslint-config-triple/prettier', ], } ================================================ FILE: chapter10/react-gradual-demo/.gitignore ================================================ node_modules build .DS_Store src/*/shared ================================================ FILE: chapter10/react-gradual-demo/.prettierrc ================================================ "@titicaca/prettier-config-triple" ================================================ FILE: chapter10/react-gradual-demo/LICENSE ================================================ MIT License Copyright (c) Facebook, Inc. and its affiliates. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: chapter10/react-gradual-demo/README.md ================================================ # Demo of Gradual React Upgrades https://github.com/reactjs/react-gradual-upgrade-demo 프로젝트의 일부 불필요한 내용을 제거하고 간단하게 변경한 프로젝트입니다. ## 프로젝트 구조 ### modern react@17.x 로 구성된 프로젝트이며, gradual demo를 실행하는 기본 리액트 프로젝트 입니다. ### legacy react@16.x로 구성된 프로젝트이며, modern에서 import 하여 사용되는 프로젝트 입니다. ### shared `modern`, `legacy` 두 개의 프로젝트에서 모두 사용되는 파일로 구성되어 있으며, 훅과 Context를 제공합니다. 훅과 context 가 `modern`과 `legacy` 모두에서 사용될 수 있음을 보여주기 위해 만들어진 예제 파일입니다. ## 동작 방식 1. 프로젝트를 빌드 합니다. 2. 빌드와 동시에 `shared`에 있는 내용을 각각 `modern`과 `legacy`로 복사합니다. 이는 마치 `npm`에서 업로드된 라이브러리를 사용하는 것과 비슷한 구조로 동작합니다. 3. 프로젝트를 시작합니다. ================================================ FILE: chapter10/react-gradual-demo/package.json ================================================ { "name": "react-gradual-demo", "version": "0.1.0", "private": true, "dependencies": { "react-scripts": "^5.0.1" }, "devDependencies": { "@titicaca/eslint-config-triple": "^5.0.0", "@titicaca/prettier-config-triple": "^1.0.2", "cpx": "1.5.0", "eslint": "^8.38.0", "npm-run-all": "4.1.5", "prettier": "^2.8.7" }, "scripts": { "//": "watch:* 명령어와 함께 react-script로 앱 시작", "postinstall": "run-s install:*", "install:legacy": "cd src/legacy && npm install", "install:modern": "cd src/modern && npm install", "copy:legacy": "cpx 'src/shared/**' 'src/legacy/shared/'", "copy:modern": "cpx 'src/shared/**' 'src/modern/shared/'", "watch:legacy": "cpx 'src/shared/**' 'src/legacy/shared/' --watch --no-initial", "watch:modern": "cpx 'src/shared/**' 'src/modern/shared/' --watch --no-initial", "prebuild": "run-p copy:*", "prestart": "run-p copy:*", "start": "run-p start-app watch:*", "start-app": "react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject", "lint": "eslint . --fix", "prettier": "prettier . --write" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: chapter10/react-gradual-demo/public/index.html ================================================ React App
================================================ FILE: chapter10/react-gradual-demo/src/index.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import './modern/index'; ================================================ FILE: chapter10/react-gradual-demo/src/legacy/Greeting.js ================================================ import React, {Component} from 'react'; import {findDOMNode} from 'react-dom'; import ThemeContext from './shared/ThemeContext'; import Clock from './shared/Clock'; export default class AboutSection extends Component { componentDidMount() { // eslint-disable-next-line react/no-find-dom-node const legacyDomNode = findDOMNode(this); // eslint-disable-next-line no-console console.log(legacyDomNode) } handleClick = () => { // eslint-disable-next-line no-console console.log('hello') } render() { return ( {theme => ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions

src/legacy/Greeting.js

This component is rendered by the nested React ({React.version}).

)}
); } } ================================================ FILE: chapter10/react-gradual-demo/src/legacy/README.md ================================================ # legacy `react@16`을 기반으로 작성된 컴포넌트 ================================================ FILE: chapter10/react-gradual-demo/src/legacy/createLegacyRoot.js ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import ThemeContext from './shared/ThemeContext'; export default function createLegacyRoot(container) { return { // 렌더링 render(Component, props, context) { ReactDOM.render( , container ); }, // 이 컴포넌트의 부모 컴포넌트가 제거될 때 호출될 unmount unmount() { ReactDOM.unmountComponentAtNode(container); }, }; } ================================================ FILE: chapter10/react-gradual-demo/src/legacy/package.json ================================================ { "private": true, "name": "react@16 application", "dependencies": { "react": "16.8", "react-dom": "16.8" } } ================================================ FILE: chapter10/react-gradual-demo/src/modern/AboutPage.js ================================================ import React, {useContext} from 'react'; import Clock from './shared/Clock'; import ThemeContext from './shared/ThemeContext'; import lazyLegacyRoot from './lazyLegacyRoot'; const Greeting = lazyLegacyRoot(() => import('../legacy/Greeting')); export default function AboutPage() { const theme = useContext(ThemeContext); return ( <>

src/modern/AboutPage.js

This component is rendered by the outer React ({React.version}).


); } ================================================ FILE: chapter10/react-gradual-demo/src/modern/App.js ================================================ import React, {useState, Suspense} from 'react'; import AboutPage from './AboutPage'; import ThemeContext from './shared/ThemeContext'; export default function App() { const [theme, setTheme] = useState('slategrey'); function handleToggleClick() { if (theme === 'slategrey') { setTheme('hotpink'); } else { setTheme('slategrey'); } } return (

}>
); } function Spinner() { return null; } ================================================ FILE: chapter10/react-gradual-demo/src/modern/HomePage.js ================================================ import React, {useContext} from 'react'; import ThemeContext from './shared/ThemeContext'; import Clock from './shared/Clock'; export default function HomePage() { const theme = useContext(ThemeContext); return ( <>

src/modern/HomePage.js

This component is rendered by the outer React ({React.version}).

); } ================================================ FILE: chapter10/react-gradual-demo/src/modern/README.md ================================================ # modern `react@17`을 기반으로 작성된 컴포넌트가 모여있으며, 애플리케이션의 시작점이다. `react@17` 을 루트에 선언해 두면, 자식 컴포넌트의 리액트 버전은 17 외에도 가능하다. ================================================ FILE: chapter10/react-gradual-demo/src/modern/index.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import React, {StrictMode} from 'react'; import ReactDOM from 'react-dom'; import App from './App'; ReactDOM.render( , document.getElementById('root') ); ================================================ FILE: chapter10/react-gradual-demo/src/modern/lazyLegacyRoot.js ================================================ import React, {useContext, useMemo, useRef, useLayoutEffect} from 'react'; import ThemeContext from './shared/ThemeContext'; const rendererModule = { status: 'pending', promise: null, result: null, }; export default function lazyLegacyRoot(getLegacyComponent) { const componentModule = { status: 'pending', promise: null, result: null, }; return function Wrapper(props) { // legacy/createLegacyRoot 를 promise 로 layzy 하게 불러온다. const createLegacyRoot = readModule(rendererModule, () => import('../legacy/createLegacyRoot') ).default; const Component = readModule(componentModule, getLegacyComponent).default; // 구 리액트를 렌더링할 위치 const containerRef = useRef(null); // 구 리액트의 루트 컴포넌트 const rootRef = useRef(null); const theme = useContext(ThemeContext); const context = useMemo( () => ({ theme, }), [theme] ); useLayoutEffect(() => { // 루트 컴포넌트가 없다면 if (!rootRef.current) { // 루트 컴포넌트를 만든다. rootRef.current = createLegacyRoot(containerRef.current); } const root = rootRef.current; // cleanUp 시에 unmount return () => { root.unmount(); }; }, [createLegacyRoot]); useLayoutEffect(() => { if (rootRef.current) { // 루트 컴포넌트가 존재하면 적절한 props와 context로 렌더링한다. rootRef.current.render(Component, props, context); } }, [Component, props, context]); return
; }; } function readModule(record, importStatement) { // promise가 없으면 아직 import 하지 못한 것이므로 import 를 실행한다. if (!record.promise) { /* eslint-disable */ record.promise = importStatement().then( value => { if (record.status === 'pending') { record.status = 'fulfilled'; record.promise = null; // 성공시 import 반환 값 record.result = value; return value } }, error => { if (record.status === 'pending') { record.status = 'rejected'; record.promise = null; // 실패시 에러 record.result = error; } } ); } // 성공 또는 실패시에 결과를 반환한다. if (record.status === 'fulfilled' || record.status === 'rejected') { return record.result; } throw record.promise; } ================================================ FILE: chapter10/react-gradual-demo/src/modern/package.json ================================================ { "private": true, "name": "react@17 application", "dependencies": { "react": "17.0.0", "react-dom": "17.0.0" } } ================================================ FILE: chapter10/react-gradual-demo/src/shared/Clock.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import React from 'react'; import useTime from './useTime'; export default function Clock() { const time = useTime(); return

Time: {time}

; } ================================================ FILE: chapter10/react-gradual-demo/src/shared/README.md ================================================ # @shared `react@16`과 `react@17`에서 공통으로 사용하는 패키지. npm 에서 제공하는 리액트 라이브러리와 비슷하게 보면 된다. ================================================ FILE: chapter10/react-gradual-demo/src/shared/ThemeContext.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import {createContext} from 'react'; const ThemeContext = createContext(null); export default ThemeContext; ================================================ FILE: chapter10/react-gradual-demo/src/shared/useTime.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import {useState, useEffect} from 'react'; export default function useTimer() { const [value, setValue] = useState(() => new Date()); useEffect(() => { const id = setInterval(() => { setValue(new Date()); }, 1000); return () => clearInterval(id); }, []); return value.toLocaleTimeString(); } ================================================ FILE: chapter11/next13/.eslintignore ================================================ .next node_modules ================================================ FILE: chapter11/next13/.eslintrc.js ================================================ module.exports = { extends: [ 'next/core-web-vitals', '@titicaca/eslint-config-triple', '@titicaca/eslint-config-triple/frontend', '@titicaca/eslint-config-triple/prettier', ], rules: { '@typescript-eslint/naming-convention': ['off'], }, } ================================================ FILE: chapter11/next13/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: chapter11/next13/.npmrc ================================================ legacy-peer-deps=true ================================================ FILE: chapter11/next13/.prettierignore ================================================ .next node_modules ================================================ FILE: chapter11/next13/.prettierrc ================================================ "@titicaca/prettier-config-triple" ================================================ FILE: chapter11/next13/README.md ================================================ # Chapter8 react@18 nextjs@13 예제 https://react-deep-dive-example-six.vercel.app/ https://github.com/vercel/app-playground 저장소에서 제공하는 예제를 조금 더 간결하고 이해하기 쉽도록 재구성한 저장소입니다. 스타일과 예제 내역을 참고했으며, 구체적인 예제는 코드 설명을 위해 조금씩 수정을 가미했습니다. 원본 예제를 알고 싶다면 vercel의 원래 저장소를 참고해주세요. ## warning - 스타일이 대부분 [tailwindcss](https://tailwindcss.com/)를 기반으로 작성되어 있기 때문에 `className`이 조금 지저분 할 수 있습니다. `className`은 대부분 스타일을 위해 사용되고 있으니 굳이 `className`을 이해하실 필요는 없습니다. - 2023년 5월 기준 `typescript@5.1.0-beta` 와 `styled-components@6.0.0-rc.1`가 일부 리액트 서버 컴포넌트 관련한 코드를 지원하기 시작하여 release candidate 버전임에도 설치했습니다. - `typescript@5.1.0-beta` 설치로 인해 아래와 같이 `peerDependencies`의 버전을 제대로 추론하지 못하는 문제를 해결하기 위해 `.npmrc`에 `legacy-peer-deps=true` 옵션을 추가했습니다. 이 문제는 향후에 `typescript@5.1.0`이 정식으로 출시되면 해결 될 것입니다. ```text npm ERR! Could not resolve dependency: npm ERR! peerOptional typescript@">=3.3.1" from eslint-config-next@13.4.1 npm ERR! node_modules/eslint-config-next npm ERR! dev eslint-config-next@"^13.4.0" from the root project npm ERR! npm ERR! Conflicting peer dependency: typescript@5.0.4 npm ERR! node_modules/typescript npm ERR! peerOptional typescript@">=3.3.1" from eslint-config-next@13.4.1 npm ERR! node_modules/eslint-config-next npm ERR! dev eslint-config-next@"^13.4.0" from the root project ``` ================================================ FILE: chapter11/next13/app/api/hello/route.ts ================================================ export async function GET() { return new Response(JSON.stringify({ name: 'John Doe' }), { status: 200, headers: { 'content-type': 'application/json', }, }) } ================================================ FILE: chapter11/next13/app/api/posts/[id]/route.ts ================================================ import type { NextRequest } from 'next/server' export async function GET( req: NextRequest, context: { params: { id: string } }, ) { const response = await fetch( `https://jsonplaceholder.typicode.com/posts/${context.params.id}`, ) console.log(context) const result = await response.json() const now = new Date() const nowStr = `${now.toLocaleDateString()} ${now.toLocaleTimeString()}` // eslint-disable-next-line no-console console.log('request has been resolved', nowStr) return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json', }, }) } ================================================ FILE: chapter11/next13/app/api/posts/route.ts ================================================ import type { NextRequest } from 'next/server' export async function GET(request: NextRequest) { const response = await fetch('https://jsonplaceholder.typicode.com/posts') const result = await response.json() return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json', }, }) } ================================================ FILE: chapter11/next13/app/api/users/[id]/route.ts ================================================ import type { NextRequest } from 'next/server' export async function GET( request: NextRequest, context: { params: { id: string } }, ) { const response = await fetch( `https://jsonplaceholder.typicode.com/users/${context.params.id}`, ) const result = await response.json() const now = new Date() const nowStr = `${now.toLocaleDateString()} ${now.toLocaleTimeString()}` // eslint-disable-next-line no-console console.log('request has been resolved', nowStr) return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json', }, }) } ================================================ FILE: chapter11/next13/app/api/users/[id].ts ================================================ ================================================ FILE: chapter11/next13/app/api/users/route.ts ================================================ export async function GET() { const response = await fetch('https://jsonplaceholder.typicode.com/users') const result = await response.json() return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json', }, }) } ================================================ FILE: chapter11/next13/app/context/[id]/page.tsx ================================================ import Counter from '#components/Counter' import { fetchUserById } from '#services/server' export default async function Page({ params }: { params: { id: string } }) { const user = await fetchUserById(params.id) return (

이름: {user.name}

) } ================================================ FILE: chapter11/next13/app/context/layout.tsx ================================================ import { ReactNode } from 'react' import { fetchUsers } from '#services/server' import { CounterProvider } from '#context/counter' import { TabGroup } from '#components/TabGroup' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/context/page.tsx ================================================ export default function Page() { return (

Client Context

  • Context는 클라이언트 컴포넌트로, 기존에 리액트에서 사용하던 Context 문법을 그대로 사용하면 서버와 클라이언트 컴포넌트 모두에서 사용할 수 있다.
  • Context는 상태를 가지고 있어야 하므로 클라이언트 컴포넌트가 될 수 밖에 없으며, 반드시 파일 상단에 "use client"를 선언해주어야 한다.
  • Context.Provider로 하위 라우팅을 감싸주면, 라우팅 내부에서 이동이 발생하더라도 Context 내부의 값잉 유지되는 것을 볼 수 있다.
) } ================================================ FILE: chapter11/next13/app/error/[id]/page.tsx ================================================ import { notFound } from 'next/navigation' import { ReactNode } from 'react' export default function Page(): ReactNode | Promise { if (true.toString() === 'true') { notFound() } return <>렌더링 되지 않습니다. } ================================================ FILE: chapter11/next13/app/error/error.tsx ================================================ 'use client' import { useEffect } from 'react' // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function Error({ error, reset }: any) { useEffect(() => { // eslint-disable-next-line no-console console.log('logging error:', error) }, [error]) return (
Error: {error?.message}
) } ================================================ FILE: chapter11/next13/app/error/layout.tsx ================================================ import { ReactNode } from 'react' import { fetchUsers } from '#services/server' import { TabGroup } from '#components/TabGroup' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/error/not-found.tsx ================================================ export default function NotFound() { return '404 입니다😭' } ================================================ FILE: chapter11/next13/app/error/page.tsx ================================================ import ErrorButton from '#components/ErrorButton' export default function Page() { return (

Error Handling

  • `error`는 nextjs의 또 다른 예약어로, 해당 라우트 내부에서 사용가능한 에러 바운더리를 정의할 수 있는 파일이다.
  • 에러 버튼을 눌러보자. 해당 라우트 내부의 레이아웃과 페이지에만 영향을 미치며, 여전히 다른 페이지는 상호작용이 가능하다.
  • `not-found`파일을 활용하면 해당 라우트 내부의 404 페이지를 정의할 수 있다.
  • 유저 버튼을 누르면 /error/{'{id}'}로 이동하는데 이 페이지는 `notFound()`를 실행하여 404페이지로 보낸다.
  • 주의: `error`는 반드시 클라이언트 컴포넌트여야 한다.
) } ================================================ FILE: chapter11/next13/app/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; ================================================ FILE: chapter11/next13/app/grouped-layouts/(main)/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' export default async function Layout({ children }: { children: ReactNode }) { return (
{children}
) } ================================================ FILE: chapter11/next13/app/grouped-layouts/(main)/page.tsx ================================================ export default function Page() { return (

Route Groups

  • 라우팅 그룹은 URL 구조에 영향을 주지 않으면서, 주소에 따라 서로다른 레이아웃을 적용할 수 있는 방법이다.
  • 만약 대표 루트 URL 페이지가 선언되어 있지 않다면 `(main)`을 해당 페이지의 루트로 간주하여 라우팅한다.
  • 서로 다른 탭을 네비게이션 해보면, 주소에는 영향이 없지만 주소에 따라서 서로 다른 레이아웃을 적용할 수 있다는 것을 알 수 있다.
) } ================================================ FILE: chapter11/next13/app/grouped-layouts/(todos)/hello/page.tsx ================================================ export default async function Page() { return (

url: /groped-layouts/hello

안녕하세요.
) } ================================================ FILE: chapter11/next13/app/grouped-layouts/(todos)/layout.tsx ================================================ import { ReactNode } from 'react' export default async function Layout({ children }: { children: ReactNode }) { return (

여기는 /groped-layout/(todos) 입니다.

{children}
) } ================================================ FILE: chapter11/next13/app/grouped-layouts/(todos)/todos/page.tsx ================================================ import { fetchTodos } from '#services/server' export default async function Page() { const todos = await fetchTodos() return (

url: /groped-layouts/todos

    {todos.map((todo) => (
  • {todo.title}
  • ))}
) } ================================================ FILE: chapter11/next13/app/grouped-layouts/(users)/layout.tsx ================================================ import { ReactNode } from 'react' export default async function Layout({ children }: { children: ReactNode }) { return (

여기는 /groped-layout/(user) 입니다.

{children}
) } ================================================ FILE: chapter11/next13/app/grouped-layouts/(users)/users/page.tsx ================================================ import { fetchUsers } from '#services/server' export default async function Page() { const users = await fetchUsers() return (

url: /groped-layouts/users

    {users.map((user) => (
  • {user.name}
  • ))}
) } ================================================ FILE: chapter11/next13/app/head/[userId]/head.tsx ================================================ import DefaultHeader from '#components/DefaultHeader' import { API_URL_BASE } from '#services/constant' export default async function Head({ params }: { params: { userId: string } }) { const response = await fetch(`${API_URL_BASE}/api/users/${params.userId}`) const user = await response.json() return ( <> {user.name} ) } ================================================ FILE: chapter11/next13/app/head/[userId]/page.tsx ================================================ import SubPage from './sub' import { API_URL_BASE } from '#services/constant' export default async function Page({ params }: { params: { userId: string } }) { const response = await fetch(`${API_URL_BASE}/api/users/${params.userId}`) const user = await response.json() return ( <>

이름: {user.name}

{/* @ts-expect-error Async Server Component */} ) } ================================================ FILE: chapter11/next13/app/head/[userId]/sub.tsx ================================================ import { API_URL_BASE } from '#services/constant' export default async function SubPage({ params, }: { params: { userId: string } }) { const response = await fetch(`${API_URL_BASE}/api/users/${params.userId}`) const user = await response.json() return (

서브페이지 이름: {user.name}

) } ================================================ FILE: chapter11/next13/app/head/head.tsx ================================================ import DefaultHeader from '#components/DefaultHeader' export default function Head() { return ( <> 라우트 내부에서 head를 재정의할 수 있습니다. ) } ================================================ FILE: chapter11/next13/app/head/layout.tsx ================================================ import { ReactNode } from 'react' import { fetchUsers } from '#services/server' import { TabGroup } from '#components/TabGroup' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/head/page.tsx ================================================ export default function Page() { return (

head 태그 설정하기

  • `head`를 활용하면 라우트 내부의 {''}를 원하는 대로 커스텀할 수 있다.
  • 또한 `head`내부에서 데이터를 불러와서 동적으로 결정하는 것 또한 가능하다.
  • Next will dedupe requests for the same data across `layout.js`, `page.js` and `head.js` when rendering a route.
  • nextjs는 `head` 내부에 데이터 요청이 있다면 이 요청이 완료되고{' '} {''}가 렌더링이 완료될 때 까지 기다린다. 이는 첫번째 스트리밍 응답에 무조건 {''}가 포함될 수 있도록 보장해준다.
  • 추가로 같은 라우트 내부에서 발생하는 같은 중복 api 요청에 대한 처리도 잘되어 있는 것을 확인할 수 있다. 이는 프로덕션 모드에서만 확인가능하며, {'/head/{id}'}페이지에서 같은 api를 3차례 부르지만 한번만 요청이 가는 것을 확인할 수 있다.
  • 클라이언트에서의 중복처리도 원래 가능한 것으로 알려져 있지만, 현재 클라이언트 요청에 대한 중복처리는 아직 개발중인 것으로 보인다. (2023년 1월 기준) 관련 링크 보기
) } ================================================ FILE: chapter11/next13/app/head.tsx ================================================ import DefaultHeader from '../src/components/DefaultHeader' export default function Head() { return ( <> Next@13 예제 ) } ================================================ FILE: chapter11/next13/app/internal-api/hello/route.ts ================================================ import { NextRequest } from 'next/server' export async function GET(request: NextRequest) { return new Response(JSON.stringify({ name: 'hello' }), { status: 200, headers: { 'content-type': 'application/json', }, }) } ================================================ FILE: chapter11/next13/app/isr/[id]/page.tsx ================================================ import { fetchPostById } from '#services/server' export const dynamicParams = true export const revalidate = 15 // revalidate this page every 60 seconds export async function generateStaticParams() { return [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }] } export default async function Page({ params }: { params: { id: string } }) { const data = await fetchPostById(params.id) console.log(`generate page ${params.id}`) return (
마지막 렌더링 시간 (프로덕션 모드만 확인 가능): UTC{' '} {new Date().toLocaleTimeString()}

{data.title}

{data.body}

) } ================================================ FILE: chapter11/next13/app/isr/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' const ids = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }] export default function Layout({ children }: { children: ReactNode }) { return (
({ text: `Post ${x.id}`, slug: x.id, })), ]} />
{children}
) } ================================================ FILE: chapter11/next13/app/isr/page.tsx ================================================ export default function Page() { return (

Incremental Static Regeneration

  • 이 예제에서는 과거 `getStaticProps`와 `revalidate`의 조합으로 제공하는 `incremental static regeneration`을 구현한 예제다.
  • 이 하위 페이지들은 15초 간격으로 페이지를 재생성하며, 15초 이내에 방문한 사용자에 대해서는 기존에 캐싱된 페이지를 보여준다.
  • 먼저 최초에 페이지를 방문하면 캐싱된 페이지를 보여준다. 그리고 만약 그 방문 시점이 revalidate시간, 즉 생성후 15초를 지났다면 새로 페이지를 다시 만들고, 그 이후에 방문한 사용자에게는 재생성한 페이지를 보여준다.
  • 우측 상단의 마지막으로 렌더링된 시간을 주목해서 살펴보자.
) } ================================================ FILE: chapter11/next13/app/layout.tsx ================================================ import './globals.css' import { ReactNode } from 'react' import SideBar from '#components/Sidebar' export default function Layout({ children }: { children: ReactNode }) { return (
{children}
) } ================================================ FILE: chapter11/next13/app/layouts/[userId]/page.tsx ================================================ import { fetchUserById } from '#services/server' export default async function Page({ params }: { params: { userId: string } }) { const user = await fetchUserById(params.userId) if (!user) { return null } return (

이름: {user.name}

) } ================================================ FILE: chapter11/next13/app/layouts/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' import { fetchUsers } from '#services/server' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/layouts/page.tsx ================================================ export default function Page() { return (

Layouts

  • 레이아웃은 특정 주소 내부에 공유할 수 있는 UI 를 말한다.
  • 네비게이션이 발생하더라도 레이아웃은 그 상태를 유지하고, 다시 렌더링하지 않는다.
  • 레이아웃은 여러 페이지에 걸쳐 중첩하는 것 또한 가능하다.
) } ================================================ FILE: chapter11/next13/app/loading/[userId]/page.tsx ================================================ import { sleep } from '#lib/utils' import { fetchUserById } from '#services/server' export default async function Page({ params }: { params: { userId: string } }) { await sleep(5 * 1000) const user = await fetchUserById(params.userId) if (!user) { return null } return (

이름: {user.name}

) } ================================================ FILE: chapter11/next13/app/loading/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' import { fetchUsers } from '#services/server' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/loading/loading.tsx ================================================ export default function Loading() { return (

Loading...

로딩 중...
) } ================================================ FILE: chapter11/next13/app/loading/page.tsx ================================================ export default function Page() { return (

Loading

  • 파일명 loading은 nextjs에서 사용하는 예약어로, 페이지가 아직 렌더링 준비가 되지 않았을 때 노출되는 컴포넌트다.
  • Streaming 예제의 Suspense와 다르게, 별도로 Suspense로 감싸지 않아도 하위 라우팅 내부에서 공통으로 사용할 수 있다는 장점이 있다.
  • 유저를 클릭하면, 유저에 해당하는 컴포넌트가 렌더링 되기 전까지 loading.tsx가 잠깐 노출되는 것을 확인할 수 있다.
) } ================================================ FILE: chapter11/next13/app/page.tsx ================================================ import Link from 'next/link' import { demos } from '../src/constant/menu' export default function Page() { return (

Examples

{demos.map((section) => { return (
{section.name}
{section.items.map((item) => { return (
{item.name}
{item.description ? (
{item.description}
) : null} ) })}
) })}
) } ================================================ FILE: chapter11/next13/app/server-action/form/[id]/loading.tsx ================================================ export default function Loading() { return (

Loading...

로딩 중...
) } ================================================ FILE: chapter11/next13/app/server-action/form/[id]/page.tsx ================================================ import kv from '@vercel/kv' import { revalidatePath } from 'next/cache' interface Data { name: string age: number } export default async function Page({ params }: { params: { id: string } }) { const key = `test:${params.id}` const data = await kv.get(key) async function handleSubmit(formData: FormData) { 'use server' const name = formData.get('name') const age = formData.get('age') await kv.set(key, { name, age, }) revalidatePath(`/server-action/form/${params.id}`) } return (

form with data

서버에 저장된 정보: {data?.name} {data?.age}

  • 아래 버튼을 누르면 서버에서 직접 form 요청을 보냅니다.
) } ================================================ FILE: chapter11/next13/app/server-action/form/page.tsx ================================================ export default function Page() { async function handleSubmit() { 'use server' console.log('해당 작업은 서버에서 수행합니다. 따라서 CORS 이슈가 없습니다.') const response = await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'post', body: JSON.stringify({ title: 'foo', body: 'bar', userId: 1, }), headers: { 'Content-type': 'application/json; charset=UTF-8', }, }) const result = await response.json() console.log(result) } return (

form

  • 아래 버튼을 누르면 서버에서 직접 form 요청을 보냅니다.
) } ================================================ FILE: chapter11/next13/app/server-action/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' export default async function Layout({ children }: { children: ReactNode }) { const items = [ { text: 'Home', }, ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/server-action/page.tsx ================================================ export default function Page() { return (

Server action (alpha)

  • 서버 액션은 컴포넌트에서 직접 서버사이드 데이터 조작을 할 수 있게 해주는 nextjs의 새로운 기능이다.
  • 13.4.0 기준으로 실험 기능이므로, `next.config.js`에서 `experimental.serverActions = true`로 설정해두어야 한다.
  • 서버 액션에서 할 수 있는 것들을 서브 메뉴로 확인해보자.
) } ================================================ FILE: chapter11/next13/app/server-action/start-transition/[id]/page.tsx ================================================ import kv from '@vercel/kv' import { ClientButtonComponent } from '#components/server-action/client-component' interface Data { name: string age: number } export default async function Page({ params }: { params: { id: string } }) { const key = `test:${params.id}` const data = await kv.get(key) return (

form with data

서버에 저장된 정보: {data?.name} {data?.age}

  • 아래 버튼을 누르면 서버에서 직접 form 요청을 보냅니다.
  • 이 작업은 useTransition을 기반으로 실행됩니다.
) } ================================================ FILE: chapter11/next13/app/ssg/[id]/page.tsx ================================================ import { fetchPostById } from '#services/server' export async function generateStaticParams() { return [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }] } export default async function Page({ params }: { params: { id: string } }) { const data = await fetchPostById(params.id) return (

{data.title}

{data.body}

) } ================================================ FILE: chapter11/next13/app/ssg/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' const ids = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }] export default function Layout({ children }: { children: ReactNode }) { return (
({ text: `Post ${x.id}`, slug: x.id, })), ]} />
마지막 렌더링 시간 (프로덕션 모드만 확인 가능) {new Date().toLocaleTimeString()}
{children}
) } ================================================ FILE: chapter11/next13/app/ssg/page.tsx ================================================ export default function Page() { return (

Static-Site Generation

  • 이 예제는 과거 `getStaticProps`와 `getStaticPaths`를 구현한 예제다. `getStaticPaths`는 `generateStaticParams`으로 대체되었으며, 데이터를 불러오는 것은 `fetch`를 사용하는 것으로 동일하다. 최초 빌드시에 미리 데이터를 불러오고, 이후 재요청이 있으면 계속 해당 데이터를 사용한다.
  • 미리 빌드된 페이지를 확인하고 싶다면, `./next/server/app/ssg`로 이동해서 확인해보면 된다. 미리 빌드된 html 파일이 준비되어 있을 것이다.
) } ================================================ FILE: chapter11/next13/app/ssr/[id]/page.tsx ================================================ import { fetchPostById } from '#services/server' export default async function Page({ params }: { params: { id: string } }) { const data = await fetchPostById(params.id, { cache: 'no-cache' }) return (

{data.title}

{data.body}

) } ================================================ FILE: chapter11/next13/app/ssr/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' import { fetchUsers } from '#services/server' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/ssr/page.tsx ================================================ export default function Page() { return (

Static-Site Generation

  • 서버사이드 렌더링을 수행하면, HTML 페이지를 매 요청이 있을 때 마다 새로 만들게 된다. 서버에서는 HTML과 요청의 결과에 따른 JSON 데이터와 함께 클라이언트에 필요한 자바스크립트 파일이 전송된다.
  • `./next/server/app/ssr`를 확인해보면, `/ssg` 페이지와는 다르게 미리 빌드된 결과물 없이 항상 데이터를 `fetch`할 준비만 되어 있는 것을 볼 수 있다.
  • 클라이언트에서는 이벤트 핸들러 등이 추가되지 않은 정적인 HTML을 받아서 페이지를 미리 보여주고, 리액트는 이 정적인 페이지에 JSON 데이터와 자바스크립트를 받아 컴포넌트를 상호작용 가능한 페이지로 만들어 준다. 이러한 일련의 과정을 hydration 이라고 한다.
) } ================================================ FILE: chapter11/next13/app/streaming/[id]/components.tsx ================================================ import { sleep } from '#lib/utils' import { fetchPosts, fetchUsers } from '#services/server' export async function Users() { // Suspense를 보기 위해 강제로 지연시킵니다. await sleep(3 * 1000) const users = await fetchUsers() return (
    {users.map((user) => (
  • {user.name}
  • ))}
) } export async function PostByUserId({ userId }: { userId: string }) { await sleep(5 * 1000) const allPosts = await fetchPosts() const posts = allPosts.filter((post) => post.userId === parseInt(userId, 10)) return (
    {posts.map((post) => (
  • {post.title}
  • ))}
) } ================================================ FILE: chapter11/next13/app/streaming/[id]/page.tsx ================================================ import { Suspense } from 'react' import { PostByUserId, Users } from './components' export default async function Page({ params }: { params: { id: string } }) { return (
유저 목록을 로딩중입니다.
}> {/* 타입스크립트에서 Promise 컴포넌트에 대해 에러를 내기 때문에 임시 처리 */} {/* @ts-expect-error Async Server Component */} 유저 {params.id}의 작성 글을 로딩중입니다.
} > {/* @ts-expect-error Async Server Component */} ) } ================================================ FILE: chapter11/next13/app/streaming/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' import { fetchUsers } from '#services/server' export default async function Layout({ children }: { children: ReactNode }) { const users = await fetchUsers() const items = [ { text: 'Home', }, ...users.map((user) => ({ text: user.name, slug: user.id.toString(), })), ] return (
{children}
) } ================================================ FILE: chapter11/next13/app/streaming/page.tsx ================================================ export default async function Page() { return (

Streaming with Suspense

  • 스트리밍을 활용하면 서버에서 클라이언트로 UI 컴포넌트를 점진적으로 조금씩 보내는 것(스트리밍)이 가능해진다.
  • 스트리밍을 활용하면 서버사이드렌더링과 다르게, 전체 페이지를 모두 보여줄 때 까지 기다리게 하는 것이 아니라 필요한 부분 부터 먼저 렌더링을 마치고 인터랙션할 수 있는 상태로 제공하는 것이 가능해진다.
  • 위 유저 목록 중 하나를 누르면 유저 컴포넌트로 가는데, 이 컴포넌트는 각각 유저목록과 유저의 작성 글을 서로다른 Suspense 내부에서 불러온다. 이를 활용하면 `loading` 컴포넌트를 사용했을 때 보다 더 세밀하게 로딩을 보여줄 수 있다.
{[1, 2, 3, 4, 5].map((id) => (
))}
) } ================================================ FILE: chapter11/next13/app/styles/css-modules/page.tsx ================================================ import styles from './styles.module.css' const SkeletonCard = () => (
) export default function Page() { return (

Styled with CSS Modules

) } ================================================ FILE: chapter11/next13/app/styles/css-modules/styles.module.css ================================================ .container { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 1.5rem /* 24px */; } @media (min-width: 1024px) { .container { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .skeleton { padding: 1rem /* 16px */; border-radius: 1rem /* 16px */; background-color: rgb(24 24 27 / 0.8); } .skeleton-img, .skeleton-btn, .skeleton-line-one, .skeleton-line-two { border-radius: 0.5rem /* 8px */; } .skeleton-img { height: 3.5rem /* 56px */; background-color: rgb(63 63 70 / 1); } .skeleton-btn, .skeleton-line-one, .skeleton-line-two { margin-top: 0.75rem /* 12px */; height: 0.75rem /* 12px */; } .skeleton-btn { background-color: rgb(121 40 202 / 1); width: 25%; } .skeleton-line-one, .skeleton-line-two { background-color: rgb(63 63 70 / 1); } .skeleton-line-one { width: 91.666667%; } .skeleton-line-two { width: 66.666667%; } ================================================ FILE: chapter11/next13/app/styles/global-css/page.tsx ================================================ import './style.css' const SkeletonCard = () => (
) export default function Page() { return (

Styled with a Global CSS Stylesheet

) } ================================================ FILE: chapter11/next13/app/styles/global-css/style.css ================================================ .container { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 1.5rem /* 24px */; } @media (min-width: 1024px) { .container { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .skeleton { padding: 1rem /* 16px */; border-radius: 1rem /* 16px */; background-color: rgb(24 24 27 / 0.8); } .skeleton-img, .skeleton-btn, .skeleton-line-one, .skeleton-line-two { border-radius: 0.5rem /* 8px */; } .skeleton-img { height: 3.5rem /* 56px */; background-color: rgb(63 63 70 / 1); } .skeleton-btn, .skeleton-line-one, .skeleton-line-two { margin-top: 0.75rem /* 12px */; height: 0.75rem /* 12px */; } .skeleton-btn { background-color: rgb(245 166 35 / 1); width: 25%; } .skeleton-line-one, .skeleton-line-two { background-color: rgb(63 63 70 / 1); } .skeleton-line-one { width: 91.666667%; } .skeleton-line-two { width: 66.666667%; } ================================================ FILE: chapter11/next13/app/styles/layout.tsx ================================================ import { ReactNode } from 'react' import { TabGroup } from '#components/TabGroup' const items = [ { text: 'Global CSS', slug: 'global-css', }, { text: 'CSS Modules', slug: 'css-modules', }, { text: 'Styled Components', slug: 'styled-components', }, { text: 'Styled JSX', slug: 'styled-jsx', }, ] export default function Layout({ children }: { children: ReactNode }) { return (
{children}
) } ================================================ FILE: chapter11/next13/app/styles/page.tsx ================================================ export default function Page() { return (

Styling

  • 스타일을 적용하는 다양한 방법
) } ================================================ FILE: chapter11/next13/app/styles/styled-components/layout.tsx ================================================ import { ReactNode } from 'react' import StyledComponentsRegistry from '#components/StyledComponentsRegistry' export default function Layout({ children }: { children: ReactNode }) { return {children} } ================================================ FILE: chapter11/next13/app/styles/styled-components/page.tsx ================================================ import { SkeletonInner, SkeletonImg, SkeletonBtn, SkeletonLineOne, SkeletonLineTwo, Container, } from '#components/components' const Skeleton = () => ( ) export default function Page() { return (

Styled Components (styled로 만들어진 컴포넌트는 반드시 클라이언트 컴포넌트 여야 합니다.)

) } ================================================ FILE: chapter11/next13/app/styles/styled-jsx/StyledRegistry.tsx ================================================ 'use client' import { ReactNode, useState } from 'react' import { useServerInsertedHTML } from 'next/navigation' import { StyleRegistry, createStyleRegistry } from 'styled-jsx' export default function StyledJsxRegistry({ children, }: { children: ReactNode }) { // Only create stylesheet once with lazy initial state // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state const [jsxStyleRegistry] = useState(() => createStyleRegistry()) useServerInsertedHTML(() => { const styles = jsxStyleRegistry.styles() jsxStyleRegistry.flush() return <>{styles} }) return {children} } ================================================ FILE: chapter11/next13/app/styles/styled-jsx/components.tsx ================================================ 'use client' export const SkeletonCard = () => ( <>
{/* eslint-disable-next-line react/no-unknown-property */} ) ================================================ FILE: chapter11/next13/app/styles/styled-jsx/layout.tsx ================================================ import { ReactNode } from 'react' import StyledJsxRegistry from './StyledRegistry' export default function Layout({ children }: { children: ReactNode }) { return {children} } ================================================ FILE: chapter11/next13/app/styles/styled-jsx/page.tsx ================================================ 'use client' import { SkeletonCard } from './components' export default function Page() { return (

Styled JSX ({'

) } ================================================ FILE: chapter11/next13/middleware.ts ================================================ import { NextRequest, NextResponse } from 'next/server' export function middleware(request: NextRequest) { const requestHeaders = new Headers(request.headers) requestHeaders.set('x-middleware-request', 'request') const response = NextResponse.next({ request: { headers: requestHeaders, }, }) response.headers.set('x-middleware-response', 'response') return response } ================================================ FILE: chapter11/next13/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, experimental: { serverActions: true, }, } module.exports = nextConfig ================================================ FILE: chapter11/next13/package.json ================================================ { "name": "next13", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "dev:turbo": "next dev --turbo", "build": "next build", "start": "next start", "lint": "eslint .", "lint:fix": "eslint . --fix", "prettier": "prettier . --check", "prettier:fix": "prettier . --write" }, "dependencies": { "@types/node": "18.11.18", "@vercel/kv": "^0.1.2", "clsx": "^1.2.1", "next": "^13.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": "6.0.0-rc.1", "typescript": "5.1.0-beta" }, "devDependencies": { "@titicaca/eslint-config-triple": "^5.0.0", "@titicaca/prettier-config-triple": "^1.0.2", "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", "autoprefixer": "^10.4.13", "eslint": "^8.38.0", "eslint-config-next": "^13.4.0", "postcss": "^8.4.21", "prettier": "^2.8.7", "tailwindcss": "^3.2.4" } } ================================================ FILE: chapter11/next13/postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: chapter11/next13/src/components/Counter.tsx ================================================ 'use client' import { useCallback } from 'react' import { useCounter } from '../context/counter' const Counter = () => { const [count, setCount] = useCounter() const handleClick = useCallback( () => setCount((prev) => prev + 1), [setCount], ) return ( ) } export default Counter ================================================ FILE: chapter11/next13/src/components/DefaultHeader.tsx ================================================ import { memo } from 'react' function DefaultHeader() { return ( <> ) } export default memo(DefaultHeader) ================================================ FILE: chapter11/next13/src/components/ErrorButton.tsx ================================================ 'use client' import { useCallback, useState } from 'react' export default function ErrorButton() { const [clicked, setClicked] = useState(false) const handleButtonClick = useCallback(() => { setClicked(true) }, []) if (clicked) { // 임의로 발생시킨 에러 throw new Error('clicked 로 인해 발생한 에러') } return ( ) } ================================================ FILE: chapter11/next13/src/components/Sidebar.tsx ================================================ 'use client' import Link from 'next/link' import { useSelectedLayoutSegment } from 'next/navigation' import { clsx } from 'clsx' import { useCallback, useState } from 'react' import { demos, type Item } from '#constant/menu' export default function SideBar() { const [open, setOpen] = useState(false) const handleClose = useCallback(() => setOpen(false), []) const handleButtonClick = useCallback(() => setOpen((prev) => !prev), []) return (

Next@13 App Directory 예제

) } function GlobalNavItem({ item, close, }: { item: Item close: () => false | void }) { const segment = useSelectedLayoutSegment() const isActive = item.slug === segment return ( {item.name} ) } ================================================ FILE: chapter11/next13/src/components/StyledComponentsRegistry.tsx ================================================ 'use client' import { ReactNode, useState } from 'react' import { useServerInsertedHTML } from 'next/navigation' import { ServerStyleSheet, StyleSheetManager } from 'styled-components' export default function StyledComponentsRegistry({ children, }: { children: ReactNode }) { const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet()) useServerInsertedHTML(() => { const styles = styledComponentsStyleSheet.getStyleElement() styledComponentsStyleSheet.instance.clearTag() return <>{styles} }) if (typeof window !== 'undefined') { return <>{children} } return ( <>{children} ) } ================================================ FILE: chapter11/next13/src/components/Tab.tsx ================================================ 'use client' import { clsx } from 'clsx' import Link from 'next/link' import { useSelectedLayoutSegment } from 'next/navigation' import { type Item } from './TabGroup' export const Tab = ({ path, item: { slug, text }, }: { path: string item: Item }) => { const segment = useSelectedLayoutSegment() const href = slug ? path + '/' + slug : path const isActive = // Example home pages e.g. `/layouts` (!slug && segment === null) || // Nested pages e.g. `/layouts/electronics` segment === slug return ( {text} ) } ================================================ FILE: chapter11/next13/src/components/TabGroup.tsx ================================================ import { Tab } from './Tab' export interface Item { text: string slug?: string } export const TabGroup = ({ path, items }: { path: string; items: Item[] }) => { return (
{items.map((item) => ( ))}
) } ================================================ FILE: chapter11/next13/src/components/components.ts ================================================ 'use client' import styled from 'styled-components' export const Container = styled.div` display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.5rem /* 24px */; ` export const SkeletonInner = styled.div` padding: 1rem /* 16px */; background-color: rgb(24 24 27 / 0.8); border-radius: 1rem /* 16px */; ` export const SkeletonImg = styled.div` height: 3.5rem /* 56px */; border-radius: 0.5rem /* 8px */; background-color: rgb(63 63 70 / 1); ` export const SkeletonBtn = styled.div` margin-top: 0.75rem /* 12px */; width: 25%; height: 0.75rem /* 12px */; border-radius: 0.5rem /* 8px */; background-color: rgb(255 0 128 / 1); ` export const SkeletonLineOne = styled.div` margin-top: 0.75rem /* 12px */; height: 0.75rem /* 12px */; width: 91.666667%; border-radius: 0.5rem /* 8px */; background-color: rgb(63 63 70 / 1); ` export const SkeletonLineTwo = styled.div` margin-top: 0.75rem /* 12px */; height: 0.75rem /* 12px */; width: 66.666667%; border-radius: 0.5rem /* 8px */; background-color: rgb(63 63 70 / 1); ` ================================================ FILE: chapter11/next13/src/components/server-action/client-component.tsx ================================================ 'use client' import { useCallback, useTransition } from 'react' import { updateData } from '#server-action' import { SkeletonBtn } from '#components/components' export function ClientButtonComponent({ id }: { id: string }) { const [isPending, startTransition] = useTransition() const handleClick = useCallback(() => { startTransition(() => updateData(id, { name: '기본값', age: 0 })) }, []) return isPending ? ( ) : ( ) } ================================================ FILE: chapter11/next13/src/constant/menu.ts ================================================ export interface Item { name: string slug: string description: string } export const demos: Array<{ name: string; items: Item[] }> = [ { name: 'Layouts', items: [ { name: 'Nested Layouts', slug: 'layouts', description: '중첩 레이아웃 - 주소에 따라 적용할 수 있는 레이아웃', }, { name: 'Grouped Layouts', slug: 'grouped-layouts', description: '그룹 레이아웃 - 주소에 영향을 미치지 않고 특정 주소에 따라 그룹화', }, ], }, { name: 'File Conventions', items: [ { name: 'loading.js', slug: 'loading', description: '데이터를 불러오거나 렌더링하는 동안 표시할 수 있는 로딩 컴포넌트', }, { name: 'error.js', slug: 'error', description: '에러 발생시 렌더링할 수 있는 에러 컴포넌트', }, { name: 'head.js', slug: 'head', description: 'URL에 따라 보여줄 수 있는 head', }, ], }, { name: 'Data Fetching', items: [ { name: 'Static-Site Generation', slug: 'ssg', description: '기존 getStaticProps을 nextjs@13에서 구현하는 방법', }, { name: 'Server-Side Rendering', slug: 'ssr', description: '기존 `getServerSideProps`를 nextjs@13에서 구현하는 방법', }, { name: 'Incremental Static Regeneration', slug: 'isr', description: '기존 `getStaticProps`와 revalidate 옵션을 구현하는 방법', }, { name: 'Streaming with Suspense', slug: 'streaming', description: 'React Suspense를 활용한 서버 스트리밍 데이터 불러오기', }, ], }, { name: 'Components', items: [ { name: 'Client context', slug: 'context', description: '`Context`는 상태를 가지고 있으므로 반드시 클라이언트 컴포넌트여야 한다.', }, ], }, { name: 'Styles', items: [ { name: 'CSS and CSS-in-JS', slug: 'styles', description: '스타일을 적용하는 다양한 방법', }, ], }, { name: 'Server Action', items: [ { name: 'form action', slug: 'server-action/form', description: '서버액션을 form과 함께 사용해보기', }, { name: 'form action with data', slug: 'server-action/form/1', description: '서버액션을 form과 데이터를 기반으로 사용해보기', }, { name: 'form action with useTransition', slug: 'server-action/start-transition/1', description: '서버액션을 useTransition과 함께 사용해보기', }, ], }, ] ================================================ FILE: chapter11/next13/src/context/counter.tsx ================================================ 'use client' import { createContext, Dispatch, SetStateAction, useState, useContext, ReactNode, } from 'react' const CounterContext = createContext< [number, Dispatch>] | undefined >(undefined) export function CounterProvider({ children }: { children: ReactNode }) { const [count, setCount] = useState(0) return ( {children} ) } export function useCounter() { const context = useContext(CounterContext) if (context === undefined) { throw new Error('useCounter must be used within a CounterProvider') } return context } ================================================ FILE: chapter11/next13/src/lib/utils.ts ================================================ export async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } ================================================ FILE: chapter11/next13/src/server-action/index.ts ================================================ 'use server' import kv from '@vercel/kv' import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' export async function updateData( id: string, data: { name: string; age: number }, ) { const key = `test:${id}` await kv.set(key, { name: data.name, age: data.age, }) revalidatePath(`/server-action/form/${id}`) } ================================================ FILE: chapter11/next13/src/services/constant.ts ================================================ export const API_URL_BASE = process.env.VERCEL_URL ? 'https://' + process.env.VERCEL_URL : 'http://localhost:3000' ================================================ FILE: chapter11/next13/src/services/server.ts ================================================ interface User { id: number name: string email: string address: { street: string suite: string city: string zipcode: string geo: { lat: string lng: string } } phone: string website: string company: { name: string catchPhrase: string bs: string } } export async function fetchUsers(): Promise> { const response = await fetch('https://jsonplaceholder.typicode.com/users') const result: Array = await response.json() return result } export async function fetchUserById(id: string | number): Promise { const response = await fetch( `https://jsonplaceholder.typicode.com/users/${id}`, ) const result: User = await response.json() return result } interface Todo { userId: number id: number title: string completed: boolean } export async function fetchTodos(): Promise> { const response = await fetch('https://jsonplaceholder.typicode.com/todos') const result: Array = await response.json() return result } interface Post { userId: number id: number title: string body: string } export async function fetchPosts(): Promise> { const response = await fetch('https://jsonplaceholder.typicode.com/posts') const result: Array = await response.json() return result } export async function fetchPostById( id: number | string, options?: RequestInit, ): Promise { const response = await fetch( `https://jsonplaceholder.typicode.com/posts/${id}`, options, ) const result: Post = await response.json() return result } ================================================ FILE: chapter11/next13/tailwind.config.js ================================================ const colors = require('tailwindcss/colors') /** @type {import('tailwindcss').Config} */ module.exports = { content: [ './app/**/*.{js,ts,jsx,tsx}', './src/components/**/*.{js,ts,jsx,tsx}', ], future: { hoverOnlyWhenSupported: true, }, theme: { extend: { colors: { gray: colors.zinc, 'gray-1000': 'rgb(17,17,19)', 'gray-1100': 'rgb(10,10,11)', vercel: { pink: '#FF0080', blue: '#0070F3', cyan: '#50E3C2', orange: '#F5A623', violet: '#7928CA', }, }, backgroundImage: ({ theme }) => ({ 'vc-border-gradient': `radial-gradient(at left top, ${theme( 'colors.gray.500', )}, 50px, ${theme('colors.gray.800')} 50%)`, }), keyframes: ({ theme }) => ({ rerender: { '0%': { 'border-color': theme('colors.vercel.pink'), }, '40%': { 'border-color': theme('colors.vercel.pink'), }, }, highlight: { '0%': { background: theme('colors.vercel.pink'), color: theme('colors.white'), }, '40%': { background: theme('colors.vercel.pink'), color: theme('colors.white'), }, }, shimmer: { '100%': { transform: 'translateX(100%)', }, }, translateXReset: { '100%': { transform: 'translateX(0)', }, }, fadeToTransparent: { '0%': { opacity: 1, }, '40%': { opacity: 1, }, '100%': { opacity: 0, }, }, }), }, }, } ================================================ FILE: chapter11/next13/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": "./", "paths": { "#app/*": ["app/*"], "#components/*": ["src/components/*"], "#constant/*": ["src/constant/*"], "#context/*": ["src/context/*"], "#lib/*": ["src/lib/*"], "#server-action/*": ["src/server-action/*"], "#server-action": ["src/server-action/index.ts"], "#services/*": ["src/services/*"] }, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: chapter11/server-components-demo/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # production /build /dist # notes notes/*.md # misc .DS_Store .eslintcache .env .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* # vscode .vscode ================================================ FILE: chapter11/server-components-demo/.nvmrc ================================================ lts/hydrogen ================================================ FILE: chapter11/server-components-demo/.prettierignore ================================================ # dependencies /node_modules /.pnp .pnp.js # testing /coverage # production /build /dist # misc .DS_Store .eslintcache .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* *.html *.json *.md ================================================ FILE: chapter11/server-components-demo/.prettierrc.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 'use strict'; module.exports = { arrowParens: 'always', bracketSpacing: false, singleQuote: true, jsxBracketSameLine: true, trailingComma: 'es5', printWidth: 80, }; ================================================ FILE: chapter11/server-components-demo/CODE_OF_CONDUCT.md ================================================ # Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: chapter11/server-components-demo/Dockerfile ================================================ FROM node:lts-hydrogen WORKDIR /opt/notes-app COPY package.json package-lock.json ./ RUN npm install --legacy-peer-deps COPY . . ENTRYPOINT [ "npm", "run" ] CMD [ "start" ] ================================================ FILE: chapter11/server-components-demo/LICENSE ================================================ MIT License Copyright (c) Facebook, Inc. and its affiliates. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: chapter11/server-components-demo/README.md ================================================ # Demo of sever components https://github.com/reactjs/server-components-demo 프로젝트의 일부 불필요한 내용을 제거하고 간단하게 변경한 프로젝트입니다. ## 프로젝트 구조 TBD ## 실행하기 ### 빠르게 실행하기 - 데이터 생성하기 - `docker-compose up -d` 로 detach 모드로 실행 - `docker-compose exec notes-app npm run seed`로 데이터 생성 - 애플리케이션 실행하기 - `docker-compose up` ================================================ FILE: chapter11/server-components-demo/credentials.js ================================================ module.exports = { host: process.env.DB_HOST || 'localhost', database: 'notesapi', user: 'notesadmin', password: 'password', port: '5432', }; ================================================ FILE: chapter11/server-components-demo/docker-compose.yml ================================================ version: "3.8" services: postgres: image: postgres:13 environment: POSTGRES_USER: notesadmin POSTGRES_PASSWORD: password POSTGRES_DB: notesapi ports: - '5432:5432' volumes: - ./scripts/init_db.sh:/docker-entrypoint-initdb.d/init_db.sh - db:/var/lib/postgresql/data notes-app: build: context: . depends_on: - postgres ports: - '4000:4000' environment: DB_HOST: postgres PORT: 4000 volumes: - ./notes:/opt/notes-app/notes - ./public:/opt/notes-app/public - ./scripts:/opt/notes-app/scripts - ./server:/opt/notes-app/server - ./src:/opt/notes-app/src - ./credentials.js:/opt/notes-app/credentials.js volumes: db: ================================================ FILE: chapter11/server-components-demo/notes/.gitkeep ================================================ ================================================ FILE: chapter11/server-components-demo/package.json ================================================ { "name": "react-notes", "version": "0.1.0", "private": true, "engines": { "node": ">=14.9.0" }, "license": "MIT", "dependencies": { "@babel/core": "7.21.3", "@babel/plugin-transform-modules-commonjs": "^7.21.2", "@babel/preset-react": "^7.18.6", "@babel/register": "^7.21.0", "acorn-jsx": "^5.3.2", "acorn-loose": "^8.3.0", "babel-loader": "8.3.0", "compression": "^1.7.4", "concurrently": "^7.6.0", "date-fns": "^2.29.3", "excerpts": "^0.0.3", "express": "^4.18.2", "html-webpack-plugin": "5.5.0", "marked": "^4.2.12", "nodemon": "^2.0.21", "pg": "^8.10.0", "react": "18.3.0-next-1308e49a6-20230330", "react-dom": "18.3.0-next-1308e49a6-20230330", "react-error-boundary": "^4.0.9", "react-server-dom-webpack": "18.3.0-next-1308e49a6-20230330", "resolve": "1.22.1", "rimraf": "^4.4.0", "sanitize-html": "^2.10.0", "server-only": "^0.0.1", "webpack": "5.76.2" }, "devDependencies": { "cross-env": "^7.0.3", "prettier": "1.19.1" }, "scripts": { "start": "concurrently \"npm run server:dev\" \"npm run bundler:dev\"", "start:prod": "concurrently \"npm run server:prod\" \"npm run bundler:prod\"", "server:dev": "cross-env NODE_ENV=development nodemon -- --conditions=react-server server", "server:prod": "cross-env NODE_ENV=production nodemon -- --conditions=react-server server", "bundler:dev": "cross-env NODE_ENV=development nodemon -- scripts/build.js", "bundler:prod": "cross-env NODE_ENV=production nodemon -- scripts/build.js", "prettier": "prettier --write **/*.js", "seed": "node ./scripts/seed.js" }, "babel": { "presets": [ [ "@babel/preset-react", { "runtime": "automatic" } ] ] }, "nodemonConfig": { "ignore": [ "build/*" ] }, "overrides": { "react": "18.3.0-next-1308e49a6-20230330", "react-dom": "18.3.0-next-1308e49a6-20230330" } } ================================================ FILE: chapter11/server-components-demo/public/index.html ================================================ React Notes
================================================ FILE: chapter11/server-components-demo/public/style.css ================================================ /* -------------------------------- CSSRESET --------------------------------*/ /* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */ /* Box sizing rules */ *, *::before, *::after { box-sizing: border-box; } /* Remove default padding */ ul[class], ol[class] { padding: 0; } /* Remove default margin */ body, h1, h2, h3, h4, p, ul[class], ol[class], li, figure, figcaption, blockquote, dl, dd { margin: 0; } /* Set core body defaults */ body { min-height: 100vh; scroll-behavior: smooth; text-rendering: optimizeSpeed; line-height: 1.5; } /* Remove list styles on ul, ol elements with a class attribute */ ul[class], ol[class] { list-style: none; } /* A elements that don't have a class get default styles */ a:not([class]) { text-decoration-skip-ink: auto; } /* Make images easier to work with */ img { max-width: 100%; display: block; } /* Natural flow and rhythm in articles by default */ article > * + * { margin-block-start: 1em; } /* Inherit fonts for inputs and buttons */ input, button, textarea, select { font: inherit; } /* Remove all animations and transitions for people that prefer not to see them */ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } /* -------------------------------- /CSSRESET --------------------------------*/ :root { /* Colors */ --main-border-color: #ddd; --primary-border: #037dba; --gray-20: #404346; --gray-60: #8a8d91; --gray-70: #bcc0c4; --gray-80: #c9ccd1; --gray-90: #e4e6eb; --gray-95: #f0f2f5; --gray-100: #f5f7fa; --primary-blue: #037dba; --secondary-blue: #0396df; --tertiary-blue: #c6efff; --flash-blue: #4cf7ff; --outline-blue: rgba(4, 164, 244, 0.6); --navy-blue: #035e8c; --red-25: #bd0d2a; --secondary-text: #65676b; --white: #fff; --yellow: #fffae1; --outline-box-shadow: 0 0 0 2px var(--outline-blue); --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue); /* Fonts */ --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, Helvetica, sans-serif; --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; } html { font-size: 100%; } body { font-family: var(--sans-serif); background: var(--gray-100); font-weight: 400; line-height: 1.75; } h1, h2, h3, h4, h5 { margin: 0; font-weight: 700; line-height: 1.3; } h1 { font-size: 3.052rem; } h2 { font-size: 2.441rem; } h3 { font-size: 1.953rem; } h4 { font-size: 1.563rem; } h5 { font-size: 1.25rem; } small, .text_small { font-size: 0.8rem; } pre, code { font-family: var(--monospace); border-radius: 6px; } pre { background: var(--gray-95); padding: 12px; line-height: 1.5; } code { background: var(--yellow); padding: 0 3px; font-size: 0.94rem; word-break: break-word; } pre code { background: none; } a { color: var(--primary-blue); } .text-with-markdown h1, .text-with-markdown h2, .text-with-markdown h3, .text-with-markdown h4, .text-with-markdown h5 { margin-block: 2rem 0.7rem; margin-inline: 0; } .text-with-markdown blockquote { font-style: italic; color: var(--gray-20); border-left: 3px solid var(--gray-80); padding-left: 10px; } hr { border: 0; height: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.3); } /* ---------------------------------------------------------------------------*/ .main { display: flex; height: 100vh; width: 100%; overflow: hidden; } .col { height: 100%; } .col:last-child { flex-grow: 1; } .logo { height: 20px; width: 22px; margin-inline-end: 10px; } .edit-button { border-radius: 100px; letter-spacing: 0.12em; text-transform: uppercase; padding: 6px 20px 8px; cursor: pointer; font-weight: 700; outline-style: none; } .edit-button--solid { background: var(--primary-blue); color: var(--white); border: none; margin-inline-start: 6px; transition: all 0.2s ease-in-out; } .edit-button--solid:hover { background: var(--secondary-blue); } .edit-button--solid:focus { box-shadow: var(--outline-box-shadow-contrast); } .edit-button--outline { background: var(--white); color: var(--primary-blue); border: 1px solid var(--primary-blue); margin-inline-start: 12px; transition: all 0.1s ease-in-out; } .edit-button--outline:disabled { opacity: 0.5; } .edit-button--outline:hover:not([disabled]) { background: var(--primary-blue); color: var(--white); } .edit-button--outline:focus { box-shadow: var(--outline-box-shadow); } ul.notes-list { padding: 16px 0; } .notes-list > li { padding: 0 16px; } .notes-empty { padding: 16px; } .sidebar { background: var(--white); box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.1), 0px 2px 2px rgba(0, 0, 0, 0.1); overflow-y: scroll; z-index: 1000; flex-shrink: 0; max-width: 350px; min-width: 250px; width: 30%; } .sidebar-header { letter-spacing: 0.15em; text-transform: uppercase; padding: 36px 16px 16px; display: flex; align-items: center; } .sidebar-menu { padding: 0 16px 16px; display: flex; justify-content: space-between; } .sidebar-menu > .search { position: relative; flex-grow: 1; } .sidebar-note-list-item { position: relative; margin-bottom: 12px; padding: 16px; width: 100%; display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; max-height: 100px; transition: max-height 250ms ease-out; transform: scale(1); } .sidebar-note-list-item.note-expanded { max-height: 300px; transition: max-height 0.5s ease; } .sidebar-note-list-item.flash { animation-name: flash; animation-duration: 0.6s; } .sidebar-note-open { position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; z-index: 0; border: none; border-radius: 6px; text-align: start; background: var(--gray-95); cursor: pointer; outline-style: none; color: transparent; font-size: 0px; } .sidebar-note-open:focus { box-shadow: var(--outline-box-shadow); } .sidebar-note-open:hover { background: var(--gray-90); } .sidebar-note-header { z-index: 1; max-width: 85%; pointer-events: none; } .sidebar-note-header > strong { display: block; font-size: 1.25rem; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-note-toggle-expand { z-index: 2; border-radius: 50%; height: 24px; border: 1px solid var(--gray-60); cursor: pointer; flex-shrink: 0; visibility: hidden; opacity: 0; cursor: default; transition: visibility 0s linear 20ms, opacity 300ms; outline-style: none; } .sidebar-note-toggle-expand:focus { box-shadow: var(--outline-box-shadow); } .sidebar-note-open:hover + .sidebar-note-toggle-expand, .sidebar-note-open:focus + .sidebar-note-toggle-expand, .sidebar-note-toggle-expand:hover, .sidebar-note-toggle-expand:focus { visibility: visible; opacity: 1; transition: visibility 0s linear 0s, opacity 300ms; } .sidebar-note-toggle-expand img { width: 10px; height: 10px; } .sidebar-note-excerpt { pointer-events: none; z-index: 2; flex: 1 1 250px; color: var(--secondary-text); position: relative; animation: slideIn 100ms; } .search input { padding: 0 16px; border-radius: 100px; border: 1px solid var(--gray-90); width: 100%; height: 100%; outline-style: none; } .search input:focus { box-shadow: var(--outline-box-shadow); } .search .spinner { position: absolute; right: 10px; top: 10px; } .note-viewer { display: flex; align-items: center; justify-content: center; } .note { background: var(--white); box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.1); border-radius: 8px; height: 95%; width: 95%; min-width: 400px; padding: 8%; overflow-y: auto; } .note--empty-state { margin-inline: 20px 20px; } .note-text--empty-state { font-size: 1.5rem; } .note-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap-reverse; margin-inline-start: -12px; } .note-menu { display: flex; justify-content: space-between; align-items: center; flex-grow: 1; } .note-title { line-height: 1.3; flex-grow: 1; overflow-wrap: break-word; margin-inline-start: 12px; } .note-updated-at { color: var(--secondary-text); white-space: nowrap; margin-inline-start: 12px; } .note-preview { margin-block-start: 50px; } .note-editor { background: var(--white); display: flex; height: 100%; width: 100%; padding: 58px; overflow-y: auto; } .note-editor .label { margin-bottom: 20px; } .note-editor-form { display: flex; flex-direction: column; width: 400px; flex-shrink: 0; position: sticky; top: 0; } .note-editor-form input, .note-editor-form textarea { background: none; border: 1px solid var(--gray-70); border-radius: 2px; font-family: var(--monospace); font-size: 0.8rem; padding: 12px; outline-style: none; } .note-editor-form input:focus, .note-editor-form textarea:focus { box-shadow: var(--outline-box-shadow); } .note-editor-form input { height: 44px; margin-bottom: 16px; } .note-editor-form textarea { height: 100%; max-width: 400px; } .note-editor-menu { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 12px; } .note-editor-preview { margin-inline-start: 40px; width: 100%; } .note-editor-done, .note-editor-delete { display: flex; justify-content: space-between; align-items: center; border-radius: 100px; letter-spacing: 0.12em; text-transform: uppercase; padding: 6px 20px 8px; cursor: pointer; font-weight: 700; margin-inline-start: 12px; outline-style: none; transition: all 0.2s ease-in-out; } .note-editor-done:disabled, .note-editor-delete:disabled { opacity: 0.5; } .note-editor-done { border: none; background: var(--primary-blue); color: var(--white); } .note-editor-done:focus { box-shadow: var(--outline-box-shadow-contrast); } .note-editor-done:hover:not([disabled]) { background: var(--secondary-blue); } .note-editor-delete { border: 1px solid var(--red-25); background: var(--white); color: var(--red-25); } .note-editor-delete:focus { box-shadow: var(--outline-box-shadow); } .note-editor-delete:hover:not([disabled]) { background: var(--red-25); color: var(--white); } /* Hack to color our svg */ .note-editor-delete:hover:not([disabled]) img { filter: grayscale(1) invert(1) brightness(2); } .note-editor-done > img { width: 14px; } .note-editor-delete > img { width: 10px; } .note-editor-done > img, .note-editor-delete > img { margin-inline-end: 12px; } .note-editor-done[disabled], .note-editor-delete[disabled] { opacity: 0.5; } .label { display: inline-block; border-radius: 100px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 700; padding: 4px 14px; } .label--preview { background: rgba(38, 183, 255, 0.15); color: var(--primary-blue); } .text-with-markdown p { margin-bottom: 16px; } .text-with-markdown img { width: 100%; } /* https://codepen.io/mandelid/pen/vwKoe */ .spinner { display: inline-block; transition: opacity linear 0.1s 0.2s; width: 20px; height: 20px; border: 3px solid rgba(80, 80, 80, 0.5); border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite; opacity: 0; } .spinner--active { opacity: 1; } .skeleton::after { content: 'Loading...'; } .skeleton { height: 100%; background-color: #eee; background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee); background-size: 200px 100%; background-repeat: no-repeat; border-radius: 4px; display: block; line-height: 1; width: 100%; animation: shimmer 1.2s ease-in-out infinite; color: transparent; } .skeleton:first-of-type { margin: 0; } .skeleton--button { border-radius: 100px; padding: 6px 20px 8px; width: auto; } .v-stack + .v-stack { margin-block-start: 0.8em; } .offscreen { border: 0; clip: rect(0, 0, 0, 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; width: 1px; position: absolute; } /* ---------------------------------------------------------------------------*/ @keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes shimmer { 0% { background-position: -200px 0; } 100% { background-position: calc(200px + 100%) 0; } } @keyframes slideIn { 0% { top: -10px; opacity: 0; } 100% { top: 0; opacity: 1; } } @keyframes flash { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.05); opacity: 0.9; } 100% { transform: scale(1); opacity: 1; } } ================================================ FILE: chapter11/server-components-demo/scripts/build.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 'use strict'; const path = require('path'); const rimraf = require('rimraf'); const webpack = require('webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin'); const isProduction = process.env.NODE_ENV === 'production'; rimraf.sync(path.resolve(__dirname, '../build')); webpack( { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'cheap-module-source-map', entry: [path.resolve(__dirname, '../src/framework/bootstrap.js')], output: { path: path.resolve(__dirname, '../build'), filename: 'main.js', }, module: { rules: [ { test: /\.js$/, use: 'babel-loader', exclude: /node_modules/, }, ], }, plugins: [ new HtmlWebpackPlugin({ inject: true, template: path.resolve(__dirname, '../public/index.html'), }), new ReactServerWebpackPlugin({isServer: false}), ], }, (err, stats) => { if (err) { console.error(err.stack || err); if (err.details) { console.error(err.details); } process.exit(1); return; } const info = stats.toJson(); if (stats.hasErrors()) { console.log('Finished running webpack with errors.'); info.errors.forEach((e) => console.error(e)); process.exit(1); } else { console.log('Finished running webpack.'); } } ); ================================================ FILE: chapter11/server-components-demo/scripts/init_db.sh ================================================ #!/bin/bash set -e psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL DROP TABLE IF EXISTS notes; CREATE TABLE notes ( id SERIAL PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, title TEXT, body TEXT ); EOSQL ================================================ FILE: chapter11/server-components-demo/scripts/seed.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 'use strict'; const fs = require('fs'); const path = require('path'); const {Pool} = require('pg'); const {readdir, unlink, writeFile} = require('fs/promises'); const startOfYear = require('date-fns/startOfYear'); const credentials = require('../credentials'); const NOTES_PATH = './notes'; const pool = new Pool(credentials); const now = new Date(); const startOfThisYear = startOfYear(now); // Thanks, https://stackoverflow.com/a/9035732 function randomDateBetween(start, end) { return new Date( start.getTime() + Math.random() * (end.getTime() - start.getTime()) ); } const dropTableStatement = 'DROP TABLE IF EXISTS notes;'; const createTableStatement = `CREATE TABLE notes ( id SERIAL PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, title TEXT, body TEXT );`; const insertNoteStatement = `INSERT INTO notes(title, body, created_at, updated_at) VALUES ($1, $2, $3, $3) RETURNING *`; const seedData = [ [ 'Meeting Notes', 'This is an example note. It contains **Markdown**!', randomDateBetween(startOfThisYear, now), ], [ 'Make a thing', `It's very easy to make some words **bold** and other words *italic* with Markdown. You can even [link to React's website!](https://www.reactjs.org).`, randomDateBetween(startOfThisYear, now), ], [ 'A note with a very long title because sometimes you need more words', `You can write all kinds of [amazing](https://en.wikipedia.org/wiki/The_Amazing) notes in this app! These note live on the server in the \`notes\` folder. ![This app is powered by React](https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/React_Native_Logo.png/800px-React_Native_Logo.png)`, randomDateBetween(startOfThisYear, now), ], ['I wrote this note today', 'It was an excellent note.', now], ]; async function seed() { await pool.query(dropTableStatement); await pool.query(createTableStatement); const res = await Promise.all( seedData.map((row) => pool.query(insertNoteStatement, row)) ); const oldNotes = await readdir(path.resolve(NOTES_PATH)); await Promise.all( oldNotes .filter((filename) => filename.endsWith('.md')) .map((filename) => unlink(path.resolve(NOTES_PATH, filename))) ); await Promise.all( res.map(({rows}) => { const id = rows[0].id; const content = rows[0].body; const data = new Uint8Array(Buffer.from(content)); return writeFile(path.resolve(NOTES_PATH, `${id}.md`), data, (err) => { if (err) { throw err; } }); }) ); } seed(); ================================================ FILE: chapter11/server-components-demo/server/api.server.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 'use strict'; const register = require('react-server-dom-webpack/node-register'); register(); const babelRegister = require('@babel/register'); babelRegister({ ignore: [/[\\\/](build|server|node_modules)[\\\/]/], presets: [['@babel/preset-react', {runtime: 'automatic'}]], plugins: ['@babel/transform-modules-commonjs'], }); const express = require('express'); const compress = require('compression'); const {readFileSync} = require('fs'); const {unlink, writeFile} = require('fs').promises; const {renderToPipeableStream} = require('react-server-dom-webpack/server'); const path = require('path'); const {Pool} = require('pg'); const React = require('react'); const ReactApp = require('../src/App').default; // Don't keep credentials in the source tree in a real app! const pool = new Pool(require('../credentials')); const PORT = process.env.PORT || 4000; const app = express(); app.use(compress()); app.use(express.json()); app .listen(PORT, () => { console.log(`React Notes listening at ${PORT}...`); }) .on('error', function(error) { if (error.syscall !== 'listen') { throw error; } const isPipe = (portOrPipe) => Number.isNaN(portOrPipe); const bind = isPipe(PORT) ? 'Pipe ' + PORT : 'Port ' + PORT; switch (error.code) { case 'EACCES': console.error(bind + ' requires elevated privileges'); process.exit(1); break; case 'EADDRINUSE': console.error(bind + ' is already in use'); process.exit(1); break; default: throw error; } }); function handleErrors(fn) { return async function(req, res, next) { try { return await fn(req, res); } catch (x) { next(x); } }; } app.get( '/', handleErrors(async function(_req, res) { await waitForWebpack(); const html = readFileSync( path.resolve(__dirname, '../build/index.html'), 'utf8' ); // Note: this is sending an empty HTML shell, like a client-side-only app. // However, the intended solution (which isn't built out yet) is to read // from the Server endpoint and turn its response into an HTML stream. res.send(html); }) ); async function renderReactTree(res, props) { await waitForWebpack(); const manifest = readFileSync( path.resolve(__dirname, '../build/react-client-manifest.json'), 'utf8' ); const moduleMap = JSON.parse(manifest); const {pipe} = renderToPipeableStream( React.createElement(ReactApp, props), moduleMap ); pipe(res); } function sendResponse(req, res, redirectToId) { const location = JSON.parse(req.query.location); if (redirectToId) { location.selectedId = redirectToId; } res.set('X-Location', JSON.stringify(location)); renderReactTree(res, { selectedId: location.selectedId, isEditing: location.isEditing, searchText: location.searchText, }); } app.get('/react', function(req, res) { sendResponse(req, res, null); }); const NOTES_PATH = path.resolve(__dirname, '../notes'); app.post( '/notes', handleErrors(async function(req, res) { const now = new Date(); const result = await pool.query( 'insert into notes (title, body, created_at, updated_at) values ($1, $2, $3, $3) returning id', [req.body.title, req.body.body, now] ); const insertedId = result.rows[0].id; await writeFile( path.resolve(NOTES_PATH, `${insertedId}.md`), req.body.body, 'utf8' ); sendResponse(req, res, insertedId); }) ); app.put( '/notes/:id', handleErrors(async function(req, res) { const now = new Date(); const updatedId = Number(req.params.id); await pool.query( 'update notes set title = $1, body = $2, updated_at = $3 where id = $4', [req.body.title, req.body.body, now, updatedId] ); await writeFile( path.resolve(NOTES_PATH, `${updatedId}.md`), req.body.body, 'utf8' ); sendResponse(req, res, null); }) ); app.delete( '/notes/:id', handleErrors(async function(req, res) { await pool.query('delete from notes where id = $1', [req.params.id]); await unlink(path.resolve(NOTES_PATH, `${req.params.id}.md`)); sendResponse(req, res, null); }) ); app.get( '/notes', handleErrors(async function(_req, res) { const {rows} = await pool.query('select * from notes order by id desc'); res.json(rows); }) ); app.get( '/notes/:id', handleErrors(async function(req, res) { const {rows} = await pool.query('select * from notes where id = $1', [ req.params.id, ]); res.json(rows[0]); }) ); app.get('/sleep/:ms', function(req, res) { setTimeout(() => { res.json({ok: true}); }, req.params.ms); }); app.use(express.static('build')); app.use(express.static('public')); async function waitForWebpack() { while (true) { try { readFileSync(path.resolve(__dirname, '../build/index.html')); return; } catch (err) { console.log( 'Could not find webpack build output. Will retry in a second...' ); await new Promise((resolve) => setTimeout(resolve, 1000)); } } } ================================================ FILE: chapter11/server-components-demo/server/package.json ================================================ { "type": "commonjs", "main": "./api.server.js" } ================================================ FILE: chapter11/server-components-demo/src/App.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import {Suspense} from 'react'; import Note from './Note'; import NoteList from './NoteList'; import EditButton from './EditButton'; import SearchField from './SearchField'; import NoteSkeleton from './NoteSkeleton'; import NoteListSkeleton from './NoteListSkeleton'; export default function App({selectedId, isEditing, searchText}) { return (
React Notes
New
}>
); } ================================================ FILE: chapter11/server-components-demo/src/EditButton.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 'use client'; import {useTransition} from 'react'; import {useRouter} from './framework/router'; export default function EditButton({noteId, children}) { const [isPending, startTransition] = useTransition(); const {navigate} = useRouter(); const isDraft = noteId == null; return ( ); } ================================================ FILE: chapter11/server-components-demo/src/Note.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import {format} from 'date-fns'; // Uncomment if you want to read from a file instead. // import {readFile} from 'fs/promises'; // import {resolve} from 'path'; import NotePreview from './NotePreview'; import EditButton from './EditButton'; import NoteEditor from './NoteEditor'; export default async function Note({selectedId, isEditing}) { if (selectedId === null) { if (isEditing) { return ( ); } else { return (
Click a note on the left to view something! 🥺
); } } const noteResponse = await fetch(`http://localhost:4000/notes/${selectedId}`); const note = await noteResponse.json(); let {id, title, body, updated_at} = note; const updatedAt = new Date(updated_at); // We could also read from a file instead. // body = await readFile(resolve(`./notes/${note.id}.md`), 'utf8'); // Now let's see how the Suspense boundary above lets us not block on this. // await fetch('http://localhost:4000/sleep/3000'); if (isEditing) { return ; } else { return (

{title}

Last updated on {format(updatedAt, "d MMM yyyy 'at' h:mm bb")} Edit
); } } ================================================ FILE: chapter11/server-components-demo/src/NoteEditor.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 'use client'; import {useState, useTransition} from 'react'; import {useRouter, useMutation} from './framework/router'; import NotePreview from './NotePreview'; export default function NoteEditor({noteId, initialTitle, initialBody}) { const [title, setTitle] = useState(initialTitle); const [body, setBody] = useState(initialBody); const {location} = useRouter(); const [isNavigating, startNavigating] = useTransition(); const [isSaving, saveNote] = useMutation({ endpoint: noteId !== null ? `/notes/${noteId}` : `/notes`, method: noteId !== null ? 'PUT' : 'POST', }); const [isDeleting, deleteNote] = useMutation({ endpoint: `/notes/${noteId}`, method: 'DELETE', }); async function handleSave() { const payload = {title, body}; const requestedLocation = { selectedId: noteId, isEditing: false, searchText: location.searchText, }; await saveNote(payload, requestedLocation); } async function handleDelete() { const payload = {}; const requestedLocation = { selectedId: null, isEditing: false, searchText: location.searchText, }; await deleteNote(payload, requestedLocation); } const isDraft = noteId === null; return (
e.preventDefault()}> { setTitle(e.target.value); }} />