├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .nvmrc
├── .prettierrc.json
├── CODE_OF_CONDUCT.md
├── README.md
├── netlify.toml
├── package-lock.json
├── package.json
├── public
├── favicon.ico
└── index.html
├── scripts
└── setup
└── src
├── 00-begin
├── App.js
├── README.md
└── api.js
├── 01-jsx
├── App.js
├── README.md
└── api.js
├── 02-query-field
├── App.js
├── README.md
└── api.js
├── 03-api
├── App.js
├── README.md
└── api.js
├── 04-lists
├── App.js
├── README.md
└── api.js
├── 05-form-submit
├── App.js
├── README.md
└── api.js
├── 06-components
├── App.js
├── README.md
├── Results.js
├── ResultsItem.js
├── SearchForm.js
└── api.js
├── 07-prop-types
├── App.js
├── README.md
├── Results.js
├── ResultsItem.js
├── SearchForm.js
└── api.js
├── 08-search-focus
├── App.js
├── README.md
├── Results.js
├── ResultsItem.js
├── SearchForm.js
└── api.js
├── 09-custom-hook
├── App.js
├── README.md
├── Results.js
├── ResultsItem.js
├── SearchForm.js
├── api.js
└── useGiphy.js
├── 10-loading-states
├── App.js
├── README.md
├── Results.js
├── ResultsItem.js
├── SearchForm.js
├── api.js
└── useGiphy.js
├── end
├── App.js
├── README.md
├── Results.js
├── ResultsItem.js
├── SearchForm.js
├── api.js
└── useGiphy.js
├── index.css
├── index.js
└── quiz
└── README.md
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | main:
7 | name: Test app build
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - name: Checkout repo
12 | uses: actions/checkout@v1
13 |
14 | - name: Use Node 12
15 | uses: actions/setup-node@v1
16 | with:
17 | node-version: 12
18 |
19 | - name: Install NPM dependencies
20 | run: npm ci
21 |
22 | - name: Run app build
23 | run: npm run build
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
18 | .grunt
19 |
20 | # node-waf configuration
21 | .lock-wscript
22 |
23 | # Compiled binary addons (http://nodejs.org/api/addons.html)
24 | build/Release
25 |
26 | # Dependency directory
27 | node_modules
28 |
29 | # Optional npm cache directory
30 | .npm
31 |
32 | # Optional REPL history
33 | .node_repl_history
34 |
35 | # Webpack build folders
36 | **/build/
37 | **/dist
38 |
39 | # Workshop directory (and backups)
40 | src/workshop*
41 |
42 | # Editor settings
43 | .vscode
44 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v12.14.1
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
6 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Workshop Code of Conduct
2 |
3 | All attendees, speakers, sponsors and volunteers at this workshop are required to agree with the following code of conduct. Organizers will enforce this code throughout the event. We expect cooperation from all participants to help ensure a safe environment for everybody.
4 |
5 | ## Have questions or need to report an issue?
6 |
7 | Please email Ben Ilegbodu at ben@benmvp.com.
8 |
9 | ## The Quick Version
10 |
11 | Our workshop is dedicated to providing a harassment-free workshop experience for everyone, regardless of gender, gender identity and expression, age, sexual orientation, disability, physical appearance, body size, race, ethnicity, religion (or lack thereof), or technology choices. We do not tolerate harassment of workshop participants in any form. Sexual language and imagery is not appropriate for any workshop venue, including presentations, comments, questions, video chat, Twitter and other online media. Workshop participants violating these rules may be sanctioned or expelled from the workshop _without a refund_ at the discretion of the workshop organizers.
12 |
13 | ## The Less Quick Version
14 |
15 | Harassment includes offensive verbal comments related to gender, gender identity and expression, age, sexual orientation, disability, physical appearance, body size, race, ethnicity, religion, technology choices, sexual images in public spaces, deliberate intimidation, stalking, following, harassing photography or recording, sustained disruption of talks or other events, inappropriate physical contact, and unwelcome sexual attention.
16 |
17 | Participants asked to stop any harassing behavior are expected to comply immediately.
18 |
19 | If a participant engages in harassing behavior, the workshop organizers may take any action they deem appropriate, including warning the offender or expulsion from the workshop _with no refund_.
20 |
21 | If you are being harassed, notice that someone else is being harassed, or have any other concerns, please contact a member of workshop staff immediately.
22 |
23 | Workshop staff will be happy to help participants contact venue security or local law enforcement, provide escorts, or otherwise assist those experiencing harassment to feel safe for the duration of the workshop. We value your attendance.
24 |
25 | We expect participants to follow these rules at workshop and workshop venues and workshop-related social events.
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React FUNdamentals Workshop with Ben Ilegbodu
2 |
3 | [](https://github.com/benmvp/react-workshop/pulse)
4 | [](https://github.com/benmvp/react-workshop/actions)
5 | [](#license)
6 | [](http://makeapullrequest.com)
7 |
8 | [](https://github.com/benmvp/react-workshop/watchers)
9 | [](https://github.com/benmvp/react-workshop/stargazers)
10 | [](https://twitter.com/intent/tweet?text=Check%20out%20React%20Fundamentals%20Workshop%20by%20%40benmvp!%0A%0Ahttps%3A%2F%2Fgithub.com%2Fbenmvp%2Freact-workshop)
11 |
12 | A step-by-step workshop to build a React application, all while learning React fundamentals. Best if accompanied with live facilitation by me 🙂.
13 |
14 | ## Pre-Workshop Instructions
15 |
16 | In order to maximize our time _during_ the workshop, please complete the following tasks in advance:
17 |
18 | - [ ] Set up the project (follow [setup instructions](#system-requirements) below)
19 | - [ ] Install and run [Zoom](https://zoom.us/) on the computer you'll be developing with (for remote workshops)
20 | - [ ] Set up dual monitors for live coding, if possible (for remote workshops)
21 | - [ ] Install React Developer Tools for [Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) (recommended) or [Firefox](https://addons.mozilla.org/en-GB/firefox/addon/react-devtools/)
22 | - [ ] Install a JSX-friendly code editor, such as [Visual Studio Code](https://code.visualstudio.com/)
23 | - [ ] Brush up on modern [ES.next](http://www.benmvp.com/learning-es6-series/) features, if they are unfamiliar to you
24 | - [ ] Have experience building websites with HTML, CSS, and JavaScript DOM APIs
25 |
26 | The more prepared you are for the workshop, the better it will go for you! 👍🏾
27 |
28 | ## System Requirements
29 |
30 | - [git](https://git-scm.com/) v2 or higher
31 | - [Node.js](https://nodejs.org/en/) v10 or higher
32 | - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) v6 or higher
33 |
34 | All of these must also be available in your `PATH` in order to be run globally. To verify things are set up properly, run:
35 |
36 | ```sh
37 | git --version
38 | node --version
39 | npm --version
40 | ```
41 |
42 | If your node version is version 9 or lower, you can [install `nvm`](https://github.com/creationix/nvm#install-script) to manage multiple versions of node.
43 |
44 | If you have trouble with any of these, learn more about the `PATH` environment variable and how to fix it here for [Windows](https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/) or [Mac/Linux](http://stackoverflow.com/a/24322978/971592).
45 |
46 | ## Setup
47 |
48 | After you have verified that you have the proper tools installed (and at the proper versions), getting setup _should_ be a breeze. Run the following commands:
49 |
50 | ```sh
51 | git clone https://github.com/benmvp/react-workshop.git
52 | cd react-workshop
53 | npm run setup
54 | ```
55 |
56 | This will likely take a **few minutes** to run. It will clone the repo, install all of the JavaScript dependencies needed to build our app, and setup our workshop dev directory.
57 |
58 | If it fails, please read through the error logs and see if you can figure out what the problem is. Double check that you have the proper [system requirements](#system-requirements) installed. If you are unable to figure out the problem on your own, please feel free to [file an issue](https://github.com/benmvp/react-workshop/issues/new) with _everything_ (and I mean everything) from the output of the commands you ran.
59 |
60 | ## Running the app
61 |
62 | We will be build a Giphy search app step-by-step in this workshop. To get started and verify that everything has been installed correctly, run:
63 |
64 | ```sh
65 | npm start
66 | ```
67 |
68 | The app should pop up in your default browser running at http://localhost:3000/. The app should be **completely blank** because we haven't built anything yet! But you can check out the app [deployed online](https://react-workshop.benmvp.com/) to see what the final state will look like.
69 |
70 | For those interested, the app is a standard app bootstrapped by [Create React App](https://create-react-app.dev/).
71 |
72 | ## Workshop Outline
73 |
74 | Let's learn the React fundamentals! ⚛️
75 |
76 | ### 🧔🏾 About Me
77 |
78 | Hiya! 👋🏾 My name is Ben Ilegbodu. 😄
79 |
80 | - Christian, Husband, Father of 👌🏾
81 | - Pittsburg, California
82 | - Principal Frontend Engineer at [Stitch Fix](https://www.stitchfix.com/) (and yes [we're hiring](https://www.stitchfix.com/careers/jobs)!)
83 | - [@benmvp](https://twitter.com/benmvp)
84 | - www.benmvp.com
85 | - Go Rockets! 🚀🏀
86 |
87 | ### 🕘 Schedule
88 |
89 | Each step in the workshop builds on top of the previous one. If at any point you get stuck, you can find the answers in the source code of the current step. Any step can be used as a starting point to continue on to the remaining steps.
90 |
91 | - Setup / Logistics / Intro
92 | - [Step 1 - JSX](src/01-jsx/)
93 | - [Step 2 - Query Field](src/02-query-field/)
94 | - [Step 3 - API](src/03-api/)
95 | - 😴 15 minutes
96 | - [Step 4 - Lists](src/04-lists/)
97 | - [Step 5 - Form Submit](src/05-form-submit/)
98 | - 🍕 45 minutes
99 | - [Step 6 - Components](src/06-components/)
100 | - [Step 7 - Prop Types](src/07-prop-types/)
101 | - [Step 8 - Search Focus](src/08-search-focus/)
102 | - 😴 15 minutes
103 | - [Step 9 - Custom Hook](src/09-custom-hook/)
104 | - [Step 10 - Loading States](src/10-loading-states/)
105 | - 😴 15 minutes
106 | - ❓ Q & A
107 | - [Final Quiz!](src/quiz/)
108 | - 👋🏾 Goodbye!
109 |
110 | ### ❓ Asking Questions
111 |
112 | - Please **interrupt me** and ask questions! Others likely will have the same question.
113 | - However, unrelated questions are better sent to [Twitter](https://twitter.com/benmvp) or [my AMA](http://www.benmvp.com/ama).
114 |
115 | ### 🖥️ Zoom Hygiene (for remote workshops)
116 |
117 | - Keep your **video on** (if possible) to make it feel more human and lively
118 | - Keep your **microphone muted** unless your talking to avoid background noise distractions
119 | - Answer each other's questions in the chat
120 | - Use breakout rooms to help each other
121 |
122 | ### ⭐ FUNdamental Concepts
123 |
124 | Here is what you'll come away knowing at the end of the workshop...
125 |
126 | - Using JSX syntax ([Step 1](src/01-jsx/))
127 | - Maintaining component state with `useState` hook ([Step 2](src/02-query-field/))
128 | - Handling user interaction ([Step 2](src/02-query-field/))
129 | - Making API calls with `useEffect` hook ([Step 3](src/03-api/))
130 | - Rendering dynamic lists of data ([Step 4](src/04-lists/))
131 | - Conditionally rendering components ([Step 4](src/04-lists/))
132 | - Handling HTML forms & form elements ([Step 5](src/05-form-submit/))
133 | - Writing readable, reusable and composable components ([Step 6](src/06-components/))
134 | - Type-checking props ([Step 7](src/07-prop-types/))
135 | - Interacting with the DOM directly with `useRef` hook ([Step 8](src/08-search-focus/))
136 | - Factoring out logic from components into custom hooks ([Step 9](src/09-custom-hook/))
137 | - Leveraging ES6+ to maintain application state with `useReducer` hook ([Step 10](src/10-loading-states/))
138 | - Applying component styling with CSS classes (throughout)
139 |
140 | ## 🧠 Elaboration & Feedback
141 |
142 | Each step has an Elaboration & Feedback form link at the end. After you're done with the exercise and before jumping to the next step, please take a few minutes to fill that out to solidify your learning.
143 |
144 | At the end of the workshop, I would greatly appreciate your overall feedback. [Share your workshop feedback](https://bit.ly/react-fun-ws-feedbck).
145 |
146 | ### 🤝 Code of Conduct
147 |
148 | All attendees, speakers, sponsors and volunteers at this workshop are required to agree with the [code of conduct](CODE_OF_CONDUCT.md). Organizers will enforce this code throughout the event. We expect cooperation from all participants to help ensure a safe environment for everybody.
149 |
150 | ### 👉🏾 First Step
151 |
152 | Go to [Step 0 - Begin](src/00-begin/).
153 |
154 | ## License
155 |
156 | All of the workshop material is available for **private, non-commercial use** under the [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html) license. If you would like to use this workshop to conduct your own workshop, please contact me first at ben@benmvp.com.
157 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | command = "npm run build"
3 | publish = "build/"
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-workshop",
3 | "private": true,
4 | "description": "A step-by-step workshop for learning React fundamentals.",
5 | "homepage": "https://react-workshop.benmvp.com/",
6 | "license": "GPL-3.0-only",
7 | "engines": {
8 | "node": ">=10",
9 | "npm": ">=6"
10 | },
11 | "scripts": {
12 | "setup": "scripts/setup",
13 | "start": "react-scripts start",
14 | "build": "react-scripts build",
15 | "test": "react-scripts test --env=jsdom",
16 | "eject": "react-scripts eject"
17 | },
18 | "dependencies": {
19 | "classnames": "^2.2.6",
20 | "prop-types": "^15.7.2",
21 | "react": "^16.13.1",
22 | "react-dom": "^16.13.1",
23 | "react-scripts": "^3.4.1",
24 | "url-lib": "^3.0.3"
25 | },
26 | "devDependencies": {
27 | "fs-extra": "^9.0.0",
28 | "prettier": "2.0.1"
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benmvp/react-workshop/cb7f3783b23b365b3721644f5e44cd765be23600/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
15 |
23 |
24 |
33 | React FUNdamentals Workshop with Ben Ilegbodu
34 |
35 |
36 |
37 |
38 | You need to enable JavaScript to run this app.
39 |
40 |
41 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/scripts/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const { spawnSync } = require('child_process')
4 | const { resolve } = require('path')
5 |
6 | const [, , exerciseToCopyPath = 'src/00-begin'] = process.argv
7 |
8 | const CWD = process.cwd()
9 | const WORKSHOP_PATH = resolve(CWD, 'src/workshop')
10 | const EXERCISE_PATH = resolve(CWD, exerciseToCopyPath)
11 | const INDEX_PATH = resolve(CWD, 'src/index.js')
12 |
13 | const COLOR_STYLES = {
14 | blue: { open: '\u001b[34m', close: '\u001b[39m' },
15 | dim: { open: '\u001b[2m', close: '\u001b[22m' },
16 | red: { open: '\u001b[31m', close: '\u001b[39m' },
17 | green: { open: '\u001b[32m', close: '\u001b[39m' },
18 | }
19 |
20 | const color = (modifier, message) => {
21 | return COLOR_STYLES[modifier].open + message + COLOR_STYLES[modifier].close
22 | }
23 | const blue = (message) => color('blue', message)
24 | const dim = (message) => color('dim', message)
25 | const red = (message) => color('red', message)
26 | const green = (message) => color('green', message)
27 |
28 | const logRunStart = (title, subtitle) => {
29 | console.log(blue(`▶️ Starting: ${title}`))
30 | console.log(` ${subtitle}`)
31 | }
32 |
33 | const logRunSuccess = (title) => {
34 | console.log(green(`✅ Success: ${title}\n\n`))
35 | }
36 |
37 | const run = (title, subtitle, command) => {
38 | logRunStart(title, subtitle)
39 | console.log(dim(` Running the following command: ${command}`))
40 |
41 | const result = spawnSync(command, { stdio: 'inherit', shell: true })
42 |
43 | if (result.status !== 0) {
44 | console.error(
45 | red(
46 | `🚨 Failure: ${title}. Please review the messages above for information on how to troubleshoot and resolve this issue.`,
47 | ),
48 | )
49 | process.exit(result.status)
50 | }
51 |
52 | logRunSuccess(title)
53 | }
54 |
55 | const main = async () => {
56 | run(
57 | 'System Validation',
58 | 'Ensuring the correct versions of tools are installed on this computer.',
59 | 'npx check-engine',
60 | )
61 |
62 | run(
63 | 'Dependency Installation',
64 | 'Installing third party code dependencies so the workshop works properly on this computer.',
65 | 'npm install',
66 | )
67 |
68 | // Now that the dependencies have been installed we can use `fs-extra`
69 | const { pathExists, move, copy, readFile, writeFile } = require('fs-extra')
70 |
71 | const WORKSHOP_CREATION_TITLE = 'Workshop Folder Creation'
72 | const WORKSHOP_CREATION_SUBTITLE = `Creating workshop directory from ${exerciseToCopyPath}.`
73 |
74 | logRunStart(WORKSHOP_CREATION_TITLE, WORKSHOP_CREATION_SUBTITLE)
75 |
76 | // create a backup of the workshop folder if it exists
77 | if (await pathExists(WORKSHOP_PATH)) {
78 | const now = Date.now()
79 |
80 | console.log(
81 | dim(
82 | ` Workshop folder already exists. Backing up to src/workshop-${now}`,
83 | ),
84 | )
85 | await move(WORKSHOP_PATH, resolve(`${WORKSHOP_PATH}-${now}`))
86 | }
87 |
88 | await copy(EXERCISE_PATH, WORKSHOP_PATH)
89 | await writeFile(
90 | INDEX_PATH,
91 | (await readFile(INDEX_PATH, 'utf8')).replace(
92 | /\.\/.*\/App/,
93 | './workshop/App',
94 | ),
95 | )
96 |
97 | logRunSuccess(WORKSHOP_CREATION_TITLE)
98 | }
99 |
100 | main()
101 |
--------------------------------------------------------------------------------
/src/00-begin/App.js:
--------------------------------------------------------------------------------
1 | const App = () => {
2 | return null
3 | }
4 |
5 | export default App
6 |
--------------------------------------------------------------------------------
/src/00-begin/README.md:
--------------------------------------------------------------------------------
1 | # Step 0 - Begin React Workshop
2 |
3 | 🏅 The goal of this step is to ensure you have everything set up with a running (but blank) app. We will be working in a step-by-step fashion to build a [Giphy search app](https://react-workshop.benmvp.com/).
4 |
5 | ## 📝 Tasks
6 |
7 | Complete the [setup instructions](../../README.md#setup)! It's your **last chance**! 🏃🏾♂️
8 |
9 | If you ran the setup **before today**, pull any changes to the repo and re-run the setup to ensure that you have the most up-to-date code examples:
10 |
11 | ```sh
12 | git pull --rebase=false
13 | npm run setup
14 | ```
15 |
16 | This should run pretty quickly.
17 |
18 | Finally, run the app if you haven't already!
19 |
20 | ```sh
21 | npm start
22 | ```
23 |
24 | Let's get started! 🎉
25 |
26 | ## 👉🏾 Next Step
27 |
28 | Go to [Step 1 - JSX](../01-jsx/).
29 |
--------------------------------------------------------------------------------
/src/00-begin/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/01-jsx/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const App = () => {
4 | return (
5 |
6 | Giphy Search!
7 |
8 | )
9 | }
10 |
11 | export default App
12 |
--------------------------------------------------------------------------------
/src/01-jsx/README.md:
--------------------------------------------------------------------------------
1 | # Step 1 - JSX
2 |
3 | [JSX](https://reactjs.org/docs/jsx-in-depth.html) is syntactic sugar for the plain JavaScript function [`React.createElement()`](https://reactjs.org/docs/react-api.html#createelement). React elements are the smallest building blocks of React apps that describe what you want to see on the screen.
4 |
5 | Unlike browser DOM elements, React elements are plain objects, and are cheap to create. [`ReactDOM`](https://reactjs.org/docs/react-dom.html) takes care of updating the DOM to match the React elements.
6 |
7 | 🏅 The goal of this step is to practice with JSX.
8 |
9 | > NOTE: One might confuse elements with a more widely known concept of "components." We will look closer at creating and composing components in the [Step 6](../06-components/). Elements are what components are "made of."
10 |
11 | If you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
12 |
13 | ## 🐇 Jump Around
14 |
15 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
16 |
17 | ## ⭐ Concepts
18 |
19 | - Rendering elements with JSX
20 | - Handling special element attribute names
21 | - Adding inline styles
22 |
23 | ## 📝 Tasks
24 |
25 | In [`src/workshop/App.js`](App.js), replace `null` with JSX markup. For example:
26 |
27 | ```js
28 | const App = () => {
29 | return Giphy Search!
30 | }
31 | ```
32 |
33 | You will need to import `React` in order use JSX.
34 |
35 | ```js
36 | import React from 'react'
37 |
38 | const App = () => {
39 | return Giphy Search!
40 | }
41 |
42 | export default App
43 | ```
44 |
45 | Add nested JSX markup. For example:
46 |
47 | ```js
48 | const App = () => {
49 | return (
50 |
51 | Giphy Search!
52 | This is a paragraph of text written in React
53 |
54 | )
55 | }
56 | ```
57 |
58 | Add attributes to the nested JSX markup. For example:
59 |
60 | ```js
61 | const App = () => {
62 | return (
63 |
64 | Giphy Search!
65 | This is a paragraph of text written in React
66 |
69 |
70 | )
71 | }
72 | ```
73 |
74 | Try adding classes to JSX markup, or a `` to connect inputs:
75 |
76 | ```js
77 | const App = () => {
78 | return (
79 |
80 | Giphy Search!
81 |
82 | This is a paragraph of text written in React
83 |
84 |
85 | Input label
86 |
87 |
88 |
89 | )
90 | }
91 | ```
92 |
93 | There is a slightly different syntax to pass variables to props:
94 |
95 | ```js
96 | const App = () => {
97 | const contents = 'This is a paragraph of text written in React'
98 | const inputId = 'input'
99 | const numItems = 3
100 |
101 | return (
102 |
103 | Giphy Search!
104 | {contents}
105 |
106 | Input label
107 |
112 |
113 |
114 | )
115 | }
116 | ```
117 |
118 | Lastly, add inline styles to some elements by passing an object to the `style` prop:
119 |
120 | ```js
121 | const App = () => {
122 | return (
123 |
124 | Giphy Search!
125 |
126 | This is a paragraph of text written in React
127 |
128 |
129 | Input label
130 |
136 |
137 |
138 | )
139 | }
140 | ```
141 |
142 | ## 💡 Exercises
143 |
144 | - Create two `const` variables, `GIPHY_SRC` ([this url](https://media.giphy.com/media/l41lXGxBwXYFcJoJ2/giphy.gif)) and `GIPHY_LINK` ([this url](https://gph.is/1IOrWO2))
145 | - Render an image with its `src` as `GIPHY_SRC`
146 | - Link the image to its page using `GIPHY_LINK` and open a new window
147 | - 🤓 **BONUS:** Render a ``
148 | - Create
149 | ```js
150 | const attrs = {
151 | id: 'main-text',
152 | className: 'text-center',
153 | style: { color: 'blue' },
154 | children: 'Hello',
155 | }
156 | ```
157 | - Add everything in `attrs` to the `
` **without** adding them individually
158 | - Remove all the practice JSX, leaving the skeleton of our Giphy search app:
159 | ```js
160 | const App = () => {
161 | return (
162 |
163 | Giphy Search!
164 |
165 | )
166 | }
167 | ```
168 |
169 | ## 🧠 Elaboration & Feedback
170 |
171 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+1+-+JSX). It will help seal in what you've learned.
172 |
173 | ## 👉🏾 Next Step
174 |
175 | Go to [Step 2 - Query Field](../02-query-field/).
176 |
177 | ## 📕 Resources
178 |
179 | - [Rendering Elements](https://reactjs.org/docs/rendering-elements.html)
180 | - [Introducing JSX](https://reactjs.org/docs/introducing-jsx.html)
181 | - [JSX in Depth](https://reactjs.org/docs/jsx-in-depth.html)
182 | - [React without JSX](https://reactjs.org/docs/react-without-jsx.html)
183 | - [Babel REPL](http://babeljs.io/repl/)
184 | - [Inline Styles](https://reactjs.org/docs/dom-elements.html#style)
185 |
186 | ## ❓ Questions
187 |
188 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
189 |
--------------------------------------------------------------------------------
/src/01-jsx/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/02-query-field/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | const App = () => {
4 | const [inputValue, setInputValue] = useState('')
5 |
6 | return (
7 |
8 | Giphy Search!
9 |
10 |
20 |
21 | )
22 | }
23 |
24 | export default App
25 |
--------------------------------------------------------------------------------
/src/02-query-field/README.md:
--------------------------------------------------------------------------------
1 | # Step 2 - Query Field
2 |
3 | The goal of this step is learning how to deal with forms. HTML form elements work a little bit differently from other DOM elements in React, because form elements naturally keep some internal state. Regular HTML forms _do_ work in React, but in most cases, it's convenient to have React keep track of the input that the user has entered into a form. The standard way to achieve this is with a technique called ["controlled components"](https://reactjs.org/docs/forms.html#controlled-components).
4 |
5 | [Handling events](https://reactjs.org/docs/handling-events.html) within React elements is very similar to handling events on DOM elements. Event handlers will be passed instances of [`SyntheticEvent`](https://reactjs.org/docs/events.html), a cross-browser wrapper around the browser's native event. It has the same interface as the browser's native event (including`stopPropagation()` and `preventDefault()`), except the events work identically across all browsers!
6 |
7 | 🏅 Ultimately, the goal of this step is to keep the current value of the search query field in UI state.
8 |
9 | If you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
10 |
11 |
12 | Help! I didn't finish the previous step! 🚨
13 |
14 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
15 |
16 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
17 |
18 | Re-run the setup script, but use the previous step as a starting point:
19 |
20 | ```sh
21 | npm run setup -- src/01-jsx
22 | ```
23 |
24 | This will also back up your `src/workshop` folder, saving your work.
25 |
26 | Now restart the app:
27 |
28 | ```sh
29 | npm start
30 | ```
31 |
32 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
33 |
34 |
35 |
36 | ## 🐇 Jump Around
37 |
38 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
39 |
40 | ## ⭐ Concepts
41 |
42 | - Maintaining UI state with the `useState` hook
43 | - Handling user interaction
44 | - Handling HTML form elements
45 |
46 | ## 📝 Tasks
47 |
48 | Add a search form with a query field:
49 |
50 | ```js
51 | const App = () => {
52 | return (
53 |
54 | Giphy Search!
55 |
56 |
59 |
60 | )
61 | }
62 | ```
63 |
64 | As of now, the DOM is maintaining the state of the input fields; React has no idea what the values of the fields are. They are currently ["uncontrolled components"](https://reactjs.org/docs/uncontrolled-components.html). We want to make them "controlled components" so we can keep track of their state within the app.
65 |
66 | Using the [`useState` hook](https://reactjs.org/docs/hooks-state.html), add a new state variable for the query field and pass it as the `value` of the ` `. Then for `onChange`, update the state.
67 |
68 | ```js
69 | import React, { useState } from 'react' // 👈🏾 import `useState`
70 |
71 | const App = () => {
72 | // new state variable 👇🏾
73 | const [inputValue, setInputValue] = useState('')
74 |
75 | return (
76 |
77 | Giphy Search!
78 |
79 |
90 |
91 | )
92 | }
93 | ```
94 |
95 | > NOTE: Be sure to import `useState` from the `react` package at the top.
96 |
97 | ## 💡 Exercises
98 |
99 | - Use the React Developer Tools to watch the `state` of `App` update as you type into the fields
100 | - Add a `
` below that will display "You are typing **[inputValue]** in the field." (with the displayed value in **bold**)
101 | - 🤓 **BONUS:** Add a button that when clicked will toggle the text in the `
` between being upper-cased and not
102 | - 🔑 _HINT:_ You will need to add a second `useState`
103 |
104 | ## 🧠 Elaboration & Feedback
105 |
106 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+2+-+Query+Field). It will help seal in what you've learned.
107 |
108 | ## 👉🏾 Next Step
109 |
110 | Go to [Step 3 - API](../03-api/).
111 |
112 | ## 📕 Resources
113 |
114 | - [Using the State Hook](https://reactjs.org/docs/hooks-state.html)
115 | - [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html)
116 | - [Why React hooks?](https://tylermcginnis.com/why-react-hooks/)
117 | - [Handling Events](https://reactjs.org/docs/handling-events.html)
118 | - [Lifting State Up](https://reactjs.org/docs/lifting-state-up.html)
119 | - [`SyntheticEvent`](https://reactjs.org/docs/events.html)
120 | - [Forms](https://reactjs.org/docs/forms.html)
121 | - [DOM Elements](https://reactjs.org/docs/dom-elements.html)
122 |
123 | ## ❓ Questions
124 |
125 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
126 |
--------------------------------------------------------------------------------
/src/02-query-field/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/03-api/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { getResults } from './api'
3 |
4 | const App = () => {
5 | const [inputValue, setInputValue] = useState('')
6 | const [results, setResults] = useState([])
7 |
8 | useEffect(() => {
9 | const fetchResults = async () => {
10 | try {
11 | const apiResponse = await getResults({ searchQuery: inputValue })
12 |
13 | setResults(apiResponse.results)
14 | } catch (err) {
15 | console.error(err)
16 | }
17 | }
18 |
19 | fetchResults()
20 | }, [inputValue])
21 |
22 | console.log({ inputValue, results })
23 |
24 | return (
25 |
26 | Giphy Search!
27 |
28 |
38 |
39 | )
40 | }
41 |
42 | export default App
43 |
--------------------------------------------------------------------------------
/src/03-api/README.md:
--------------------------------------------------------------------------------
1 | # Step 3 - API
2 |
3 | 🏅 The goal of this step is to retrieve a list of giphy images based on the query typed in the query input field from [Step 2](../02-query-field). We'll do this by using the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [ES6 Promises](http://www.benmvp.com/learning-es6-promises/) to retrieve the data from the [Giphy API](https://developers.giphy.com/docs/api/endpoint/), and then store the data in app state.
4 |
5 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
6 |
7 |
8 | Help! I didn't finish the previous step! 🚨
9 |
10 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
11 |
12 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
13 |
14 | Re-run the setup script, but use the previous step as a starting point:
15 |
16 | ```sh
17 | npm run setup -- src/02-query-field
18 | ```
19 |
20 | This will also back up your `src/workshop` folder, saving your work.
21 |
22 | Now restart the app:
23 |
24 | ```sh
25 | npm start
26 | ```
27 |
28 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
29 |
30 |
31 |
32 | ## 🐇 Jump Around
33 |
34 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
35 |
36 | ## ⭐ Concepts
37 |
38 | - Making API calls with the `useEffect` hook
39 | - Using Promises & `async`/`await`
40 | - Maintaining app state with the `useState` hook
41 |
42 | ## 📝 Tasks
43 |
44 | Import the `getResults` API helper along with `useEffect` from React. Then call `getResults()` within `useEffect()`, passing in the value of the input field:
45 |
46 | ```js
47 | import React, { useState, useEffect } from 'react'
48 | import { getResults } from './api'
49 | // 👆🏾 new imports
50 |
51 | const App = () => {
52 | const [inputValue, setInputValue] = useState('')
53 |
54 | // 👇🏾 call API w/ useEffect
55 | useEffect(() => {
56 | getResults({ searchQuery: inputValue })
57 | }, [inputValue])
58 |
59 | return (
60 |
61 | Giphy Search!
62 |
63 |
73 |
74 | )
75 | }
76 |
77 | export default App
78 | ```
79 |
80 | > NOTE: Be sure to import `useEffect` from the `react` package.
81 |
82 | Check the Network panel of your Developer Tools to see that it is making an API call for every character typed within the query input field. The path to interactivity has begun.
83 |
84 | In order to render the giphy images we need to store the results in state, once again leveraging `useState`:
85 |
86 | ```js
87 | const [inputValue, setInputValue] = useState('')
88 | const [results, setResults] = useState([])
89 | // 👆🏾 new state variable to contain the search results
90 |
91 | useEffect(() => {
92 | // resolve the promise to get the results 👇🏾
93 | getResults({ searchQuery: inputValue }).then((apiResponse) =>
94 | setResults(apiResponse.results),
95 | )
96 | }, [inputValue])
97 |
98 | // 👇🏾 logging the results for now
99 | console.log({ inputValue, results })
100 | ```
101 |
102 | If you prefer to use [`async` functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) over Promises, you can do that too:
103 |
104 | ```js
105 | useEffect(() => {
106 | const fetchResults = async () => {
107 | // add async 👆🏾
108 | try {
109 | // 👆🏾 try above, 👇🏾 await below
110 | const apiResponse = await getResults({ searchQuery: inputValue })
111 |
112 | setResults(apiResponse.results)
113 | } catch (err) {
114 | console.error(err)
115 | }
116 | }
117 |
118 | fetchResults()
119 | }, [inputValue])
120 |
121 | console.log({ inputValue, results })
122 | ```
123 |
124 | ## 💡 Exercises
125 |
126 | - Type in different search queries and verify the results by digging into the log and navigating to URLs
127 | - Take a look at [`api.js`](./api.js) and see what the API helper does, particularly the other search filters it supports
128 | - Pass in hard-coded values for the other search filters to `getResults()` and see how the logged data changes
129 |
130 | ## 🧠 Elaboration & Feedback
131 |
132 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+3+-+API). It will help seal in what you've learned.
133 |
134 | ## 👉🏾 Next Step
135 |
136 | Go to [Step 4 - Lists](../04-lists/).
137 |
138 | ## 📕 Resources
139 |
140 | - [Using the Effect Hook](https://reactjs.org/docs/hooks-effect.html)
141 | - [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) & Github's [`fetch` polyfill](https://github.com/github/fetch)
142 | - [Learning ES6: Promises](http://www.benmvp.com/learning-es6-promises/)
143 | - [Async functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)
144 | - [HTTP Methods](http://restfulapi.net/http-methods/)
145 | - [Postman](https://www.getpostman.com/)
146 |
147 | ## ❓ Questions
148 |
149 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
150 |
--------------------------------------------------------------------------------
/src/03-api/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/04-lists/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { getResults } from './api'
3 |
4 | const LIMITS = [6, 12, 18, 24, 30]
5 |
6 | const App = () => {
7 | const [inputValue, setInputValue] = useState('')
8 | const [searchLimit, setSearchLimit] = useState(12)
9 | const [results, setResults] = useState([])
10 |
11 | useEffect(() => {
12 | const fetchResults = async () => {
13 | try {
14 | const apiResponse = await getResults({
15 | searchQuery: inputValue,
16 | limit: searchLimit,
17 | })
18 |
19 | setResults(apiResponse.results)
20 | } catch (err) {
21 | console.error(err)
22 | }
23 | }
24 |
25 | fetchResults()
26 | }, [inputValue, searchLimit])
27 |
28 | return (
29 |
30 | Giphy Search!
31 |
32 |
55 |
56 | {results.length > 0 && (
57 |
58 | {results.map((item) => (
59 |
78 | ))}
79 |
80 | )}
81 |
82 | )
83 | }
84 |
85 | export default App
86 |
--------------------------------------------------------------------------------
/src/04-lists/README.md:
--------------------------------------------------------------------------------
1 | # Step 4 - Lists
2 |
3 | 🏅 The goal of this step is to practice transforming lists of data into lists of components which can be included in JSX. We'll take the `results` data we have in state in convert it to visible UI!
4 |
5 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
6 |
7 |
8 | Help! I didn't finish the previous step! 🚨
9 |
10 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
11 |
12 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
13 |
14 | Re-run the setup script, but use the previous step as a starting point:
15 |
16 | ```sh
17 | npm run setup -- src/03-api
18 | ```
19 |
20 | This will also back up your `src/workshop` folder, saving your work.
21 |
22 | Now restart the app:
23 |
24 | ```sh
25 | npm start
26 | ```
27 |
28 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
29 |
30 |
31 |
32 | ## 🐇 Jump Around
33 |
34 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
35 |
36 | ## ⭐ Concepts
37 |
38 | - Rendering dynamic lists of data
39 | - Handling special `key` prop
40 | - Conditionally rendering components
41 |
42 | ## 📝 Tasks
43 |
44 | We need to convert the array of results into an array of components so that we can render the giphy images. There are several ways, but the most common approach is to use [`Array.prototype.map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map):
45 |
46 | ```js
47 | return (
48 |
49 | Giphy Search!
50 |
51 |
61 |
62 |
63 | {results.map((item) => (
64 |
71 | ))}
72 |
73 |
74 | )
75 | ```
76 |
77 | > NOTE: Be sure to include the [`key` prop](https://reactjs.org/docs/lists-and-keys.html) on the `
` elements.
78 |
79 | We need to only render the `` when there are results. There are a number of different ways to [conditionally render JSX](https://reactjs.org/docs/conditional-rendering.html). The most common approach is:
80 |
81 | ```js
82 | return (
83 |
84 | Giphy Search!
85 |
86 |
96 |
97 | {results.length > 0 && ( // 👈🏾 inline if with &&
98 |
99 | {results.map((item) => (
100 |
107 | ))}
108 |
109 | )}
110 |
111 | )
112 | ```
113 |
114 | Let's add some additional markup and classes around the ` ` so we can include the giphy title:
115 |
116 | ```js
117 | {
118 | results.length > 0 && (
119 |
120 | {results.map((item) => (
121 |
135 | ))}
136 |
137 | )
138 | }
139 | ```
140 |
141 | > NOTE: Be sure to move the `key` prop to the containing `` within the `.map()`.
142 |
143 | ## 💡 Exercises
144 |
145 | - Make the displayed title (`item.title`) link to the Giphy URL (`item.url`)
146 | - Display the Giphy Rating (`item.rating`)
147 | - Add a `` to the search form to change the number of Giphy images displayed (the `limit` search param)
148 | - Map over a `const LIMITS = [6, 12, 18, 24, 30]` constant to generate the `` tags within the ``
149 | - The props should be ` { ... }}>`
150 | - The default should remain `12`
151 | - Open the Developer Tools, on the Elements tab, and monitor how the markup changes when changing limits
152 |
153 | ## 🧠 Elaboration & Feedback
154 |
155 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+4+-+Lists). It will help seal in what you've learned.
156 |
157 | ## 👉🏾 Next Step
158 |
159 | Go to [Step 5 - Form Submit](../05-form-submit/).
160 |
161 | ## 📕 Resources
162 |
163 | - [Lists and Keys](https://reactjs.org/docs/lists-and-keys.html)
164 | - [Conditional Rendering](https://reactjs.org/docs/conditional-rendering.html)
165 | - [`Array.prototype.map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)
166 |
167 | ## ❓ Questions
168 |
169 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
170 |
--------------------------------------------------------------------------------
/src/04-lists/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/05-form-submit/App.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect } from 'react'
2 | import { getResults } from './api'
3 |
4 | const RATINGS = [
5 | { value: '', label: 'All' },
6 | { value: 'g', label: 'G' },
7 | { value: 'pg', label: 'PG' },
8 | { value: 'pg-13', label: 'PG-13' },
9 | { value: 'r', label: 'R' },
10 | ]
11 | const LIMITS = [6, 12, 18, 24, 30]
12 |
13 | const App = () => {
14 | const [inputValue, setInputValue] = useState('')
15 | const [searchQuery, setSearchQuery] = useState('')
16 | const [showInstant, setShowInstant] = useState(false)
17 | const [searchRating, setSearchRating] = useState('')
18 | const [searchLimit, setSearchLimit] = useState(12)
19 | const realSearchQuery = showInstant ? inputValue : searchQuery
20 | const [results, setResults] = useState([])
21 |
22 | useEffect(() => {
23 | const fetchResults = async () => {
24 | try {
25 | const apiResponse = await getResults({
26 | searchQuery: realSearchQuery,
27 | limit: searchLimit,
28 | rating: searchRating,
29 | })
30 |
31 | setResults(apiResponse.results)
32 | } catch (err) {
33 | console.error(err)
34 | }
35 | }
36 |
37 | fetchResults()
38 | }, [realSearchQuery, searchRating, searchLimit])
39 |
40 | const handleSubmit = (e) => {
41 | e.preventDefault()
42 | setSearchQuery(inputValue)
43 | }
44 |
45 | return (
46 |
47 | Giphy Search!
48 |
49 |
112 |
113 | {results.length > 0 && (
114 |
115 | {results.map((item) => (
116 |
135 | ))}
136 |
137 | )}
138 |
139 | )
140 | }
141 |
142 | export default App
143 |
--------------------------------------------------------------------------------
/src/05-form-submit/README.md:
--------------------------------------------------------------------------------
1 | # Step 5 - Form Submit
2 |
3 | You will notice that so far we've been getting "instant" results when typing in the search query field. This means that while we're typing a search query, we're getting intermediate results for word partials. In the real word, this could hammer an API unnecessarily. In addition, by the time we get the results, we've already typed the next character so the UI feels a bit jumpy.
4 |
5 | 🏅 So the goal of this step is to require submitting the search query field in order to trigger retrieval of new results.
6 |
7 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
8 |
9 |
10 | Help! I didn't finish the previous step! 🚨
11 |
12 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
13 |
14 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
15 |
16 | Re-run the setup script, but use the previous step as a starting point:
17 |
18 | ```sh
19 | npm run setup -- src/04-lists
20 | ```
21 |
22 | This will also back up your `src/workshop` folder, saving your work.
23 |
24 | Now restart the app:
25 |
26 | ```sh
27 | npm start
28 | ```
29 |
30 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
31 |
32 |
33 |
34 | ## 🐇 Jump Around
35 |
36 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
37 |
38 | ## ⭐ Concepts
39 |
40 | - Handling client-side form submission
41 | - Maintaining UI state
42 | - Applying component styling with CSS classes
43 |
44 | ## 📝 Tasks
45 |
46 | Add a new state variable to maintain the submitted search query (calling it `searchQuery`):
47 |
48 | ```js
49 | const [inputValue, setInputValue] = useState('')
50 | const [searchQuery, setSearchQuery] = useState('') // 👈🏾 NEW!
51 | const [searchLimit, setSearchLimit] = useState(12)
52 | const [results, setResults] = useState([])
53 | ```
54 |
55 | Add an `onSubmit` handler to the `
178 |
185 |
192 |
193 |
194 | Choose a rating
195 | ...
196 |
197 |
198 |
199 | # of Results
200 | ...
201 |
202 |
203 | )
204 | }
205 | ```
206 |
207 | Back in `App.js`, we'll import the `SearchForm` component at the top of the file:
208 |
209 | ```js
210 | import React, { useState, useEffect } from 'react'
211 | import { getResults } from './api'
212 | import Results from './Results'
213 | import SearchForm from './SearchForm' // 👈🏾 new import
214 | ```
215 |
216 | And in place of the `` tag we'll render ` `:
217 |
218 | ```js
219 | return (
220 |
221 | Giphy Search!
222 |
223 |
224 |
225 | )
226 | ```
227 |
228 | Add a new `formValues` state variable and new `onChange` handler for ` `:
229 |
230 | ```js
231 | const App = () => {
232 | const [formValues, setFormValues] = useState({}) // 👈🏾 NEW!
233 | const [results, setResults] = useState([])
234 |
235 | useEffect(() => {
236 | const fetchResults = async () => {
237 | try {
238 | // pass single state variable object 👇🏾
239 | const apiResponse = await getResults(formValues)
240 |
241 | setResults(apiResponse.results)
242 | } catch (err) {
243 | console.error(err)
244 | }
245 | }
246 |
247 | fetchResults()
248 | }, [formValues]) // 👈🏾 sole useEffect dependency
249 |
250 | return (
251 |
252 | Giphy Search!
253 |
254 |
255 |
256 |
257 | )
258 | }
259 | ```
260 |
261 | We now need `SearchForm` to have a new `onChange` prop that it calls whenever its fields change, passing the same object properties that `getResults` expects (`searchQuery`, `limit` & `rating`):
262 |
263 | ```js
264 | const SearchForm = (props) => {
265 | // new `props` arg
266 | const { onChange } = props // 👈🏾 new `onChange` prop
267 | const [inputValue, setInputValue] = useState('')
268 | const [searchQuery, setSearchQuery] = useState('')
269 | const [showInstant, setShowInstant] = useState(false)
270 | const [searchRating, setSearchRating] = useState('')
271 | const [searchLimit, setSearchLimit] = useState(12)
272 | const realSearchQuery = showInstant ? inputValue : searchQuery
273 |
274 | useEffect(() => {
275 | // Call `onChange` prop in `useEffect()` that is
276 | // similar to where we called `getResults()` in `App`
277 | onChange({
278 | searchQuery: realSearchQuery,
279 | rating: searchRating,
280 | limit: searchLimit,
281 | })
282 | }, [onChange, realSearchQuery, searchRating, searchLimit])
283 |
284 | const handleSubmit = (e) => {
285 | e.preventDefault()
286 | setSearchQuery(inputValue)
287 | }
288 |
289 | return ...
290 | }
291 | ```
292 |
293 | > NOTE: `SearchForm` has an ` ` element for the query search field, an ` ` element for the instant results toggle, multiple connected ` ` elements for the rating picker, and a `` for the number of results switcher. Normally you would use a component library like [Material-UI](https://material-ui.com/) that would have those reusable components for you.
294 |
295 | ## 💡 Exercises
296 |
297 | - From `Results`, pull out a `ResultsItem` component into `src/workshop/ResultsItem.js` with 5 props: `id`, `title`, `url`, `rating` & `previewUrl`.
298 | - Use the React Developer Tools to inspect the component hierarchy, including the props being passed to the ` ` & ` ` components.
299 |
300 | ## 🧠 Elaboration & Feedback
301 |
302 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+6+-+Components). It will help seal in what you've learned.
303 |
304 | ## 👉🏾 Next Step
305 |
306 | Go to [Step 7 - Prop Types](../07-prop-types/).
307 |
308 | ## 📕 Resources
309 |
310 | - [Components and Props](https://reactjs.org/docs/components-and-props.html)
311 | - [Material-UI](https://material-ui.com/)
312 | - [React + Foundation](https://react.foundation/)
313 | - [React Bootstrap](https://react-bootstrap.github.io/)
314 | - [Tailwind CSS](https://tailwindcss.com/)
315 |
316 | ## ❓ Questions
317 |
318 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
319 |
--------------------------------------------------------------------------------
/src/06-components/Results.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ResultsItem from './ResultsItem'
3 |
4 | const Results = ({ items }) => {
5 | return (
6 | items.length > 0 && (
7 |
8 | {items.map((item) => (
9 |
17 | ))}
18 |
19 | )
20 | )
21 | }
22 |
23 | export default Results
24 |
--------------------------------------------------------------------------------
/src/06-components/ResultsItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => {
4 | return (
5 |
24 | )
25 | }
26 |
27 | export default ResultsItem
28 |
--------------------------------------------------------------------------------
/src/06-components/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect } from 'react'
2 |
3 | const RATINGS = [
4 | { value: '', label: 'All' },
5 | { value: 'g', label: 'G' },
6 | { value: 'pg', label: 'PG' },
7 | { value: 'pg-13', label: 'PG-13' },
8 | { value: 'r', label: 'R' },
9 | ]
10 | const LIMITS = [6, 12, 18, 24, 30]
11 |
12 | const SearchForm = ({ onChange }) => {
13 | const [inputValue, setInputValue] = useState('')
14 | const [searchQuery, setSearchQuery] = useState('')
15 | const [showInstant, setShowInstant] = useState(false)
16 | const [searchRating, setSearchRating] = useState('')
17 | const [searchLimit, setSearchLimit] = useState(12)
18 | const realSearchQuery = showInstant ? inputValue : searchQuery
19 |
20 | useEffect(() => {
21 | onChange({
22 | searchQuery: realSearchQuery,
23 | rating: searchRating,
24 | limit: searchLimit,
25 | })
26 | }, [onChange, realSearchQuery, searchRating, searchLimit])
27 |
28 | const handleSubmit = (e) => {
29 | e.preventDefault()
30 | setSearchQuery(inputValue)
31 | }
32 |
33 | return (
34 |
35 |
51 |
63 |
64 |
65 | Choose a rating
66 | {RATINGS.map(({ value, label }) => (
67 |
68 | {
75 | setSearchRating(value)
76 | }}
77 | />
78 | {label}
79 |
80 | ))}
81 |
82 |
83 |
84 | # of Results
85 | setSearchLimit(e.target.value)}
87 | value={searchLimit}
88 | >
89 | {LIMITS.map((limit) => (
90 |
91 | {limit}
92 |
93 | ))}
94 |
95 |
96 |
97 | )
98 | }
99 |
100 | export default SearchForm
101 |
--------------------------------------------------------------------------------
/src/06-components/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/07-prop-types/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { getResults } from './api'
3 | import Results from './Results'
4 | import SearchForm from './SearchForm'
5 |
6 | const App = () => {
7 | const [formValues, setFormValues] = useState({})
8 | const [results, setResults] = useState([])
9 |
10 | useEffect(() => {
11 | const fetchResults = async () => {
12 | try {
13 | const apiResponse = await getResults(formValues)
14 |
15 | setResults(apiResponse.results)
16 | } catch (err) {
17 | console.error(err)
18 | }
19 | }
20 |
21 | fetchResults()
22 | }, [formValues])
23 |
24 | return (
25 |
26 | Giphy Search!
27 |
28 |
33 |
34 |
35 | )
36 | }
37 |
38 | export default App
39 |
--------------------------------------------------------------------------------
/src/07-prop-types/README.md:
--------------------------------------------------------------------------------
1 | # Step 7 - Prop Types
2 |
3 | Components that accept props will define the types of props they accept. This serves two purposes:
4 |
5 | 1. Declare the public API of the component
6 | 2. Validate the props being passed in by the parent
7 |
8 | 🏅 The goal of this step is to learn how to define prop types for a component.
9 |
10 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
11 |
12 |
13 | Help! I didn't finish the previous step! 🚨
14 |
15 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
16 |
17 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
18 |
19 | Re-run the setup script, but use the previous step as a starting point:
20 |
21 | ```sh
22 | npm run setup -- src/06-components
23 | ```
24 |
25 | This will also back up your `src/workshop` folder, saving your work.
26 |
27 | Now restart the app:
28 |
29 | ```sh
30 | npm start
31 | ```
32 |
33 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
34 |
35 |
36 |
37 | ## 🐇 Jump Around
38 |
39 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
40 |
41 | ## ⭐ Concepts
42 |
43 | - Type-checking props
44 |
45 | ## 📝 Tasks
46 |
47 | Using the [`prop-types`](https://reactjs.org/docs/typechecking-with-proptypes.html) package, add a prop type in `SearchForm` for the `onChange` callback prop:
48 |
49 | ```js
50 | import React, { Fragment, useState, useEffect } from 'react'
51 | import PropTypes from 'prop-types' // 👈🏾 new import
52 |
53 | ...
54 |
55 | const SearchForm = (props) => {
56 | const { onChange } = props
57 |
58 | ...
59 | }
60 |
61 | // define types of props 👇🏾
62 | SearchForm.propTypes = {
63 | onChange: PropTypes.func.isRequired,
64 | }
65 |
66 | export default SearchForm
67 | ```
68 |
69 | Now add a prop type in `Results` for the `items` prop:
70 |
71 | ```js
72 | import React from 'react'
73 | import PropTypes from 'prop-types' // 👈🏾 new import
74 |
75 | const Results = (props) => {
76 | const { items } = props
77 |
78 | ...
79 |
80 | }
81 |
82 | // define types of props 👇🏾
83 | Results.propTypes = {
84 | items: PropTypes.arrayOf(
85 | PropTypes.shape({
86 | id: PropTypes.string.isRequired,
87 | title: PropTypes.string.isRequired,
88 | url: PropTypes.string.isRequired,
89 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
90 | previewUrl: PropTypes.string.isRequired,
91 | }),
92 | ).isRequired,
93 | }
94 |
95 | export default Results
96 | ```
97 |
98 | ## 💡 Exercises
99 |
100 | - Add make all of the props for `ResultsItem` required
101 | - Add 4 additional _optional_ props to `SearchForm`: `initialSearchQuery`, `initialShowInstant`, `initialRating` & `initialLimit`
102 | - These will set the initial values of the corresponding state variables (`useState(XXX)`)
103 | - 🔑 _HINT:_ Use [`defaultProps`](https://reactjs.org/docs/typechecking-with-proptypes.html#default-prop-values) to set the default values when the props are not specified
104 | - Add some of the `initial*` props to ` ` in `App` and use the React Developer Tools to see how the initial UI changes
105 |
106 | ## 🧠 Elaboration & Feedback
107 |
108 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+7+-+Prop+Types). It will help seal in what you've learned.
109 |
110 | ## 👉🏾 Next Step
111 |
112 | Go to [Step 8 - Search Focus](../08-search-focus/).
113 |
114 | ## 📕 Resources
115 |
116 | - [Typechecking with PropTypes](https://reactjs.org/docs/typechecking-with-proptypes.html)
117 | - [Custom Prop Types](https://github.com/airbnb/prop-types)
118 |
119 | ## ❓ Questions
120 |
121 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
122 |
--------------------------------------------------------------------------------
/src/07-prop-types/Results.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import ResultsItem from './ResultsItem'
4 |
5 | const Results = ({ items }) => {
6 | return (
7 | items.length > 0 && (
8 |
9 | {items.map((item) => (
10 |
18 | ))}
19 |
20 | )
21 | )
22 | }
23 |
24 | Results.propTypes = {
25 | items: PropTypes.arrayOf(
26 | PropTypes.shape({
27 | id: PropTypes.string.isRequired,
28 | title: PropTypes.string.isRequired,
29 | url: PropTypes.string.isRequired,
30 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
31 | previewUrl: PropTypes.string.isRequired,
32 | }),
33 | ).isRequired,
34 | }
35 |
36 | export default Results
37 |
--------------------------------------------------------------------------------
/src/07-prop-types/ResultsItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => {
5 | return (
6 |
25 | )
26 | }
27 |
28 | ResultsItem.propTypes = {
29 | id: PropTypes.string.isRequired,
30 | title: PropTypes.string.isRequired,
31 | url: PropTypes.string.isRequired,
32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
33 | previewUrl: PropTypes.string.isRequired,
34 | }
35 |
36 | export default ResultsItem
37 |
--------------------------------------------------------------------------------
/src/07-prop-types/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const RATINGS = [
5 | { value: '', label: 'All' },
6 | { value: 'g', label: 'G' },
7 | { value: 'pg', label: 'PG' },
8 | { value: 'pg-13', label: 'PG-13' },
9 | { value: 'r', label: 'R' },
10 | ]
11 | const LIMITS = [6, 12, 18, 24, 30]
12 |
13 | const SearchForm = ({
14 | initialLimit,
15 | initialRating,
16 | initialSearchQuery,
17 | initialShowInstant,
18 | onChange,
19 | }) => {
20 | const [inputValue, setInputValue] = useState(initialSearchQuery)
21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery)
22 | const [showInstant, setShowInstant] = useState(initialShowInstant)
23 | const [searchRating, setSearchRating] = useState(initialRating)
24 | const [searchLimit, setSearchLimit] = useState(initialLimit)
25 | const realSearchQuery = showInstant ? inputValue : searchQuery
26 |
27 | useEffect(() => {
28 | onChange({
29 | searchQuery: realSearchQuery,
30 | rating: searchRating,
31 | limit: searchLimit,
32 | })
33 | }, [onChange, realSearchQuery, searchRating, searchLimit])
34 |
35 | const handleSubmit = (e) => {
36 | e.preventDefault()
37 | setSearchQuery(inputValue)
38 | }
39 |
40 | return (
41 |
42 |
58 |
70 |
71 |
72 | Choose a rating
73 | {RATINGS.map(({ value, label }) => (
74 |
75 | {
82 | setSearchRating(value)
83 | }}
84 | />
85 | {label}
86 |
87 | ))}
88 |
89 |
90 |
91 | # of Results
92 | setSearchLimit(e.target.value)}
94 | value={searchLimit}
95 | >
96 | {LIMITS.map((limit) => (
97 |
98 | {limit}
99 |
100 | ))}
101 |
102 |
103 |
104 | )
105 | }
106 |
107 | SearchForm.propTypes = {
108 | initialLimit: PropTypes.number,
109 | initialRating: PropTypes.string,
110 | initialSearchQuery: PropTypes.string,
111 | initialShowInstant: PropTypes.bool,
112 | onChange: PropTypes.func.isRequired,
113 | }
114 | SearchForm.defaultProps = {
115 | initialLimit: 12,
116 | initialRating: '',
117 | initialSearchQuery: '',
118 | initialShowInstant: false,
119 | }
120 |
121 | export default SearchForm
122 |
--------------------------------------------------------------------------------
/src/07-prop-types/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/08-search-focus/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { getResults } from './api'
3 | import Results from './Results'
4 | import SearchForm from './SearchForm'
5 |
6 | const App = () => {
7 | const [formValues, setFormValues] = useState({})
8 | const [results, setResults] = useState([])
9 |
10 | useEffect(() => {
11 | const fetchResults = async () => {
12 | try {
13 | const apiResponse = await getResults(formValues)
14 |
15 | setResults(apiResponse.results)
16 | } catch (err) {
17 | console.error(err)
18 | }
19 | }
20 |
21 | fetchResults()
22 | }, [formValues])
23 |
24 | return (
25 |
26 | Giphy Search!
27 |
28 |
33 |
34 |
35 | )
36 | }
37 |
38 | export default App
39 |
--------------------------------------------------------------------------------
/src/08-search-focus/README.md:
--------------------------------------------------------------------------------
1 | # Step 8 - Search Focus
2 |
3 | When we submit the search form by clicking the "Search" button, the search button now becomes the focused element. But we want the focus to go back to the query field so that we can easily type a new search.
4 |
5 | By default, when we develop in React, we're never touching any actual DOM. Instead we're rendering UI that React efficiently applies to the DOM. But there are rare times where we'll need to interact with the DOM directly in order to mutate it in ways that React doesn't support.
6 |
7 | 🏅 So the goal of this step is to use this "escape hatch" in order to focus the search query imperatively.
8 |
9 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
10 |
11 |
12 | Help! I didn't finish the previous step! 🚨
13 |
14 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
15 |
16 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
17 |
18 | Re-run the setup script, but use the previous step as a starting point:
19 |
20 | ```sh
21 | npm run setup -- src/07-prop-types
22 | ```
23 |
24 | This will also back up your `src/workshop` folder, saving your work.
25 |
26 | Now restart the app:
27 |
28 | ```sh
29 | npm start
30 | ```
31 |
32 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
33 |
34 |
35 |
36 | ## 🐇 Jump Around
37 |
38 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
39 |
40 | ## ⭐ Concepts
41 |
42 | - Interacting with the DOM directly with `useRef` hook
43 |
44 | ## 📝 Tasks
45 |
46 | In [`SearchForm.js`](./SearchForm.js), Import the `useRef` hook from `react`:
47 |
48 | ```js
49 | import React, { Fragment, useState, useEffect, useRef } from 'react' // 👈🏾 new import
50 | ```
51 |
52 | Create a ref within `SearchForm` after all of the state variables:
53 |
54 | ```js
55 | const [inputValue, setInputValue] = useState(initialSearchQuery)
56 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery)
57 | const [showInstant, setShowInstant] = useState(initialShowInstant)
58 | const [searchRating, setSearchRating] = useState(initialRating)
59 | const [searchLimit, setSearchLimit] = useState(initialLimit)
60 | const realSearchQuery = showInstant ? inputValue : searchQuery
61 |
62 | // new ref 👇🏾
63 | const queryFieldEl = useRef(null)
64 | ```
65 |
66 | Add the ref as the `ref` prop to the query field:
67 |
68 | ```js
69 | {
74 | setInputValue(e.target.value)
75 | }}
76 | className="input-group-field"
77 | ref={queryFieldEl} // 👈🏾 ref is here
78 | />
79 | ```
80 |
81 | Back in `handleSubmit`, focus the field on submission of the form by accessing `queryFieldEl.current`:
82 |
83 | ```js
84 | const handleSubmit = (e) => {
85 | e.preventDefault()
86 | setSearchQuery(inputValue)
87 |
88 | // focus the query field after submitting
89 | // to make easier to quickly search again
90 | // 👇🏾
91 | queryFieldEl.current.focus()
92 | }
93 | ```
94 |
95 | Now when we submit the field with the "Search" button the query field goes back to being focused, just like if we submitted by pressing ENTER in the field.
96 |
97 | ## 💡 Exercises
98 |
99 | - Add a "To top" button at the bottom of the results that when clicked jumps the user to the top of the results
100 | - 🔑 _HINT:_ [`element.scrollIntoView(true)`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) will align the top of element in the window
101 | - 🤓 **BONUS:** Animate the scrolling so it's smooth instead of a jump
102 |
103 | ## 🧠 Elaboration & Feedback
104 |
105 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+8+-+Search+Focus). It will help seal in what you've learned.
106 |
107 | ## 👉🏾 Next Step
108 |
109 | Go to [Step 9 - Custom Hook](../09-custom-hook/).
110 |
111 | ## 📕 Resources
112 |
113 | - [`useRef` API Reference](https://reactjs.org/docs/hooks-reference.html#useref)
114 | - [Introduction to useRef Hook](https://dev.to/dinhhuyams/introduction-to-useref-hook-3m7n)
115 | - [Using `useRef` as an "instance variable"](https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables)
116 |
117 | ## ❓ Questions
118 |
119 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
120 |
--------------------------------------------------------------------------------
/src/08-search-focus/Results.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 | import ResultsItem from './ResultsItem'
4 |
5 | const Results = ({ items }) => {
6 | const containerEl = useRef(null)
7 |
8 | return (
9 | items.length > 0 && (
10 | <>
11 |
12 | {items.map((item) => (
13 |
21 | ))}
22 |
23 |
24 | {
28 | containerEl.current.scrollIntoView(true)
29 | }}
30 | >
31 | To top
32 |
33 |
34 | >
35 | )
36 | )
37 | }
38 |
39 | Results.propTypes = {
40 | items: PropTypes.arrayOf(
41 | PropTypes.shape({
42 | id: PropTypes.string.isRequired,
43 | title: PropTypes.string.isRequired,
44 | url: PropTypes.string.isRequired,
45 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
46 | previewUrl: PropTypes.string.isRequired,
47 | }),
48 | ).isRequired,
49 | }
50 |
51 | export default Results
52 |
--------------------------------------------------------------------------------
/src/08-search-focus/ResultsItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => {
5 | return (
6 |
25 | )
26 | }
27 |
28 | ResultsItem.propTypes = {
29 | id: PropTypes.string.isRequired,
30 | title: PropTypes.string.isRequired,
31 | url: PropTypes.string.isRequired,
32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
33 | previewUrl: PropTypes.string.isRequired,
34 | }
35 |
36 | export default ResultsItem
37 |
--------------------------------------------------------------------------------
/src/08-search-focus/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect, useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const RATINGS = [
5 | { value: '', label: 'All' },
6 | { value: 'g', label: 'G' },
7 | { value: 'pg', label: 'PG' },
8 | { value: 'pg-13', label: 'PG-13' },
9 | { value: 'r', label: 'R' },
10 | ]
11 | const LIMITS = [6, 12, 18, 24, 30]
12 |
13 | const SearchForm = ({
14 | initialLimit,
15 | initialRating,
16 | initialSearchQuery,
17 | initialShowInstant,
18 | onChange,
19 | }) => {
20 | const [inputValue, setInputValue] = useState(initialSearchQuery)
21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery)
22 | const [showInstant, setShowInstant] = useState(initialShowInstant)
23 | const [searchRating, setSearchRating] = useState(initialRating)
24 | const [searchLimit, setSearchLimit] = useState(initialLimit)
25 | const realSearchQuery = showInstant ? inputValue : searchQuery
26 | const queryFieldEl = useRef(null)
27 |
28 | useEffect(() => {
29 | onChange({
30 | searchQuery: realSearchQuery,
31 | rating: searchRating,
32 | limit: searchLimit,
33 | })
34 | }, [onChange, realSearchQuery, searchRating, searchLimit])
35 |
36 | const handleSubmit = (e) => {
37 | e.preventDefault()
38 | setSearchQuery(inputValue)
39 |
40 | // focus the query field after submitting
41 | // to make easier to quickly search again
42 | queryFieldEl.current.focus()
43 | }
44 |
45 | return (
46 |
47 |
64 |
76 |
77 |
78 | Choose a rating
79 | {RATINGS.map(({ value, label }) => (
80 |
81 | {
88 | setSearchRating(value)
89 | }}
90 | />
91 | {label}
92 |
93 | ))}
94 |
95 |
96 |
97 | # of Results
98 | setSearchLimit(e.target.value)}
100 | value={searchLimit}
101 | >
102 | {LIMITS.map((limit) => (
103 |
104 | {limit}
105 |
106 | ))}
107 |
108 |
109 |
110 | )
111 | }
112 |
113 | SearchForm.propTypes = {
114 | initialLimit: PropTypes.number,
115 | initialRating: PropTypes.string,
116 | initialSearchQuery: PropTypes.string,
117 | initialShowInstant: PropTypes.bool,
118 | onChange: PropTypes.func.isRequired,
119 | }
120 | SearchForm.defaultProps = {
121 | initialLimit: 12,
122 | initialRating: '',
123 | initialSearchQuery: '',
124 | initialShowInstant: false,
125 | }
126 |
127 | export default SearchForm
128 |
--------------------------------------------------------------------------------
/src/08-search-focus/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/09-custom-hook/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import useGiphy from './useGiphy'
3 | import Results from './Results'
4 | import SearchForm from './SearchForm'
5 |
6 | const App = () => {
7 | const [results, setSearchParams] = useGiphy()
8 |
9 | return (
10 |
11 | Giphy Search!
12 |
13 |
18 |
19 |
20 | )
21 | }
22 |
23 | export default App
24 |
--------------------------------------------------------------------------------
/src/09-custom-hook/README.md:
--------------------------------------------------------------------------------
1 | # Step 8 - Custom Hook
2 |
3 | We've been able to greatly reduce the scope of the top-level `App` by breaking it down into several components. However, it still directly makes the API call in order to maintain the app-level state. `App` is just an orchestrator, it shouldn't really **do** any work.
4 |
5 | 🏅 The goal of this step is to learn how to create our own custom hooks composed of the base hooks like `useState` & `useEffect`. This allows us to extract component logic into reusable (and testable) functions.
6 |
7 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
8 |
9 |
10 | Help! I didn't finish the previous step! 🚨
11 |
12 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
13 |
14 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
15 |
16 | Re-run the setup script, but use the previous step as a starting point:
17 |
18 | ```sh
19 | npm run setup -- src/08-search-focus
20 | ```
21 |
22 | This will also back up your `src/workshop` folder, saving your work.
23 |
24 | Now restart the app:
25 |
26 | ```sh
27 | npm start
28 | ```
29 |
30 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
31 |
32 |
33 |
34 | ## 🐇 Jump Around
35 |
36 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
37 |
38 | ## ⭐ Concepts
39 |
40 | - Creating (async) custom hooks
41 |
42 | ## 📝 Tasks
43 |
44 | Create a new file called `src/workshop/useGiphy.js` which will contain our custom hook that will take in search parameters, make an API call and return results:
45 |
46 | ```js
47 | import { useState, useEffect } from 'react'
48 | import { getResults } from './api' // 👈🏾 bring over API import
49 |
50 | const useGiphy = () => {
51 | return null
52 | }
53 |
54 | export default useGiphy
55 | ```
56 |
57 | A custom hook is a normal JavaScript function whose name **must start** with `use*` and may call other hooks, like `useState` & `useEffect`.
58 |
59 | Copy over all the hooks-related code from `App.js` into `useGiphy.js`:
60 |
61 | ```js
62 | const useGiphy = () => {
63 | const [searchParams, setSearchParams] = useState({})
64 | const [results, setResults] = useState([])
65 |
66 | useEffect(() => {
67 | const fetchResults = async () => {
68 | try {
69 | const apiResponse = await getResults(searchParams)
70 |
71 | setResults(apiResponse.results)
72 | } catch (err) {
73 | console.error(err)
74 | }
75 | }
76 |
77 | fetchResults()
78 | }, [searchParams])
79 |
80 | return [results, setSearchParams]
81 | }
82 | ```
83 |
84 | Now `useGiphy()` can easily be used w/in other components because all of the state management and side-effect API logic have been abstracted away.
85 |
86 | ## 💡 Exercises
87 |
88 | - Finish the feature by calling `useGiphy()` back in `App`
89 | - Compare the current version of [`App.js`](./App.js) with the [Step 5 `App.js`](../05-form-submit/App.js)
90 |
91 | ## 🧠 Elaboration & Feedback
92 |
93 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+9+-+Custom+Hook). It will help seal in what you've learned.
94 |
95 | ## 👉🏾 Next Step
96 |
97 | Go to [Step 10 - Loading States](../10-loading-states/).
98 |
99 | ## 📕 Resources
100 |
101 | - [Building Your Own Hooks](https://reactjs.org/docs/hooks-custom.html)
102 | - [How to test custom React hooks](https://kentcdodds.com/blog/how-to-test-custom-react-hooks)
103 | - [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html)
104 |
105 | ## ❓ Questions
106 |
107 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
108 |
--------------------------------------------------------------------------------
/src/09-custom-hook/Results.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 | import ResultsItem from './ResultsItem'
4 |
5 | const Results = ({ items }) => {
6 | const containerEl = useRef(null)
7 |
8 | return (
9 | items.length > 0 && (
10 | <>
11 |
12 | {items.map((item) => (
13 |
21 | ))}
22 |
23 |
24 | {
28 | containerEl.current.scrollIntoView(true)
29 | }}
30 | >
31 | To top
32 |
33 |
34 | >
35 | )
36 | )
37 | }
38 |
39 | Results.propTypes = {
40 | items: PropTypes.arrayOf(
41 | PropTypes.shape({
42 | id: PropTypes.string.isRequired,
43 | title: PropTypes.string.isRequired,
44 | url: PropTypes.string.isRequired,
45 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
46 | previewUrl: PropTypes.string.isRequired,
47 | }),
48 | ).isRequired,
49 | }
50 |
51 | export default Results
52 |
--------------------------------------------------------------------------------
/src/09-custom-hook/ResultsItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => {
5 | return (
6 |
25 | )
26 | }
27 |
28 | ResultsItem.propTypes = {
29 | id: PropTypes.string.isRequired,
30 | title: PropTypes.string.isRequired,
31 | url: PropTypes.string.isRequired,
32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
33 | previewUrl: PropTypes.string.isRequired,
34 | }
35 |
36 | export default ResultsItem
37 |
--------------------------------------------------------------------------------
/src/09-custom-hook/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect, useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const RATINGS = [
5 | { value: '', label: 'All' },
6 | { value: 'g', label: 'G' },
7 | { value: 'pg', label: 'PG' },
8 | { value: 'pg-13', label: 'PG-13' },
9 | { value: 'r', label: 'R' },
10 | ]
11 | const LIMITS = [6, 12, 18, 24, 30]
12 |
13 | const SearchForm = ({
14 | initialLimit,
15 | initialRating,
16 | initialSearchQuery,
17 | initialShowInstant,
18 | onChange,
19 | }) => {
20 | const [inputValue, setInputValue] = useState(initialSearchQuery)
21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery)
22 | const [showInstant, setShowInstant] = useState(initialShowInstant)
23 | const [searchRating, setSearchRating] = useState(initialRating)
24 | const [searchLimit, setSearchLimit] = useState(initialLimit)
25 | const realSearchQuery = showInstant ? inputValue : searchQuery
26 | const queryFieldEl = useRef(null)
27 |
28 | useEffect(() => {
29 | onChange({
30 | searchQuery: realSearchQuery,
31 | rating: searchRating,
32 | limit: searchLimit,
33 | })
34 | }, [onChange, realSearchQuery, searchRating, searchLimit])
35 |
36 | const handleSubmit = (e) => {
37 | e.preventDefault()
38 | setSearchQuery(inputValue)
39 |
40 | // focus the query field after submitting
41 | // to make easier to quickly search again
42 | queryFieldEl.current.focus()
43 | }
44 |
45 | return (
46 |
47 |
64 |
76 |
77 |
78 | Choose a rating
79 | {RATINGS.map(({ value, label }) => (
80 |
81 | {
88 | setSearchRating(value)
89 | }}
90 | />
91 | {label}
92 |
93 | ))}
94 |
95 |
96 |
97 | # of Results
98 | setSearchLimit(e.target.value)}
100 | value={searchLimit}
101 | >
102 | {LIMITS.map((limit) => (
103 |
104 | {limit}
105 |
106 | ))}
107 |
108 |
109 |
110 | )
111 | }
112 |
113 | SearchForm.propTypes = {
114 | initialLimit: PropTypes.number,
115 | initialRating: PropTypes.string,
116 | initialSearchQuery: PropTypes.string,
117 | initialShowInstant: PropTypes.bool,
118 | onChange: PropTypes.func.isRequired,
119 | }
120 | SearchForm.defaultProps = {
121 | initialLimit: 12,
122 | initialRating: '',
123 | initialSearchQuery: '',
124 | initialShowInstant: false,
125 | }
126 |
127 | export default SearchForm
128 |
--------------------------------------------------------------------------------
/src/09-custom-hook/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | const resp = await fetch(
47 | formatUrl(
48 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
49 | {
50 | q: searchQuery,
51 | rating,
52 | limit,
53 | offset,
54 | lang: 'en',
55 | },
56 | ),
57 | )
58 | const data = await resp.json()
59 | const results = data.data.map(({ id, title, url, images, rating }) => ({
60 | id,
61 | title,
62 | url,
63 | rating: rating.toUpperCase(),
64 | previewUrl: images.preview.mp4,
65 | }))
66 |
67 | return { results, total: data.pagination['total_count'] }
68 | }
69 |
--------------------------------------------------------------------------------
/src/09-custom-hook/useGiphy.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { getResults } from './api'
3 |
4 | /**
5 | * @typedef {import('./api').SearchParams} SearchParams
6 | * @typedef {import('./api').GiphyResult} GiphyResult
7 | *
8 | * @callback SetSearchParams
9 | * @param {SearchParams} searchParams Search parameters
10 | */
11 |
12 | /**
13 | * A custom hook that returns giphy results and a function to search for updated results
14 | * @returns {[GiphyResult[], SetSearchParams]}
15 | */
16 | const useGiphy = () => {
17 | const [searchParams, setSearchParams] = useState({})
18 | const [results, setResults] = useState([])
19 |
20 | useEffect(() => {
21 | const fetchResults = async () => {
22 | try {
23 | const apiResponse = await getResults(searchParams)
24 |
25 | setResults(apiResponse.results)
26 | } catch (err) {
27 | console.error(err)
28 | }
29 | }
30 |
31 | fetchResults()
32 | }, [searchParams])
33 |
34 | return [results, setSearchParams]
35 | }
36 |
37 | export default useGiphy
38 |
--------------------------------------------------------------------------------
/src/10-loading-states/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import useGiphy from './useGiphy'
3 | import Results from './Results'
4 | import SearchForm from './SearchForm'
5 |
6 | const App = () => {
7 | const [{ status, results, error }, setSearchParams] = useGiphy()
8 |
9 | return (
10 |
11 | Giphy Search!
12 |
13 |
18 |
19 |
20 | )
21 | }
22 |
23 | export default App
24 |
--------------------------------------------------------------------------------
/src/10-loading-states/README.md:
--------------------------------------------------------------------------------
1 | # Step 10 - Loading States
2 |
3 | Up to this point, we've assumed that the Giphy API will quickly respond and never fail. But no matter how great the uptime of an API is, the user's internet connection can determine how long it takes to get a response and if the request fails or not.
4 |
5 | 🏅 The goal of this exercise is to add loading and error states to our app.
6 |
7 | As always, if you run into trouble with the [tasks](#tasks) or [exercises](#exercises), you can take a peek at the final [source code](./).
8 |
9 |
10 | Help! I didn't finish the previous step! 🚨
11 |
12 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
13 |
14 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
15 |
16 | Re-run the setup script, but use the previous step as a starting point:
17 |
18 | ```sh
19 | npm run setup -- src/09-custom-hook
20 | ```
21 |
22 | This will also back up your `src/workshop` folder, saving your work.
23 |
24 | Now restart the app:
25 |
26 | ```sh
27 | npm start
28 | ```
29 |
30 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
31 |
32 |
33 |
34 | ## 🐇 Jump Around
35 |
36 | [Concepts](#-concepts) | [Tasks](#-tasks) | [Exercises](#-exercises) | [Elaboration & Feedback](#-elaboration--feedback) | [Resources](#-resources)
37 |
38 | ## ⭐ Concepts
39 |
40 | - Managing loading & error states
41 | - Using `useReducer`
42 | - Leveraging ES6+ to maintain application state
43 |
44 | ## 📝 Tasks
45 |
46 | ### With `useState()`
47 |
48 | Update `useGiphy` to keep track of the `status` of the API response and return it along with `results` & `setSearchParams`:
49 |
50 | ```js
51 | const useGiphy = () => {
52 | const [searchParams, setSearchParams] = useState({})
53 | const [results, setResults] = useState([])
54 | const [status, setStatus] = useState('idle') // 👈🏾 NEW
55 |
56 | useEffect(() => {
57 | const fetchResults = async () => {
58 | try {
59 | // 👇🏾 before API request
60 | setStatus('pending')
61 |
62 | const apiResponse = await getResults(searchParams)
63 |
64 | setResults(apiResponse.results)
65 |
66 | // 👇🏾 after successful API request
67 | setStatus('resolved')
68 | } catch (err) {
69 | console.error(err)
70 | }
71 | }
72 |
73 | fetchResults()
74 | }, [searchParams])
75 |
76 | // 👇🏾 returning `status` & `results` together as an object
77 | return [{ status, results }, setSearchParams]
78 | }
79 | ```
80 |
81 | Update `App` with the new return value from `useGiphy()` and pass `status` to ` `:
82 |
83 | ```js
84 | const App = () => {
85 | // Converted to object literal destructuring in order to get
86 | // out the 3 properties 👇🏾
87 | const [{ status, results }, setSearchParams] = useGiphy()
88 |
89 | return (
90 |
91 | Giphy Search!
92 |
93 |
98 | {/* add status to Results 👇🏾 */}
99 |
100 |
101 | )
102 | }
103 | ```
104 |
105 | Now display the loading indicator in `Results`:
106 |
107 | ```js
108 | // new `status` prop added 👇🏾
109 | const Results = ({ items, status }) => {
110 | const containerEl = useRef(null)
111 | const isLoading = status === 'idle' || status === 'pending'
112 |
113 | // 👇🏾 new loading indicator
114 | if (isLoading) {
115 | return (
116 |
117 | Loading new results...
118 |
119 | )
120 | }
121 |
122 | return (
123 | items.length > 0 && (
124 | ...
125 | )
126 | )
127 | }
128 |
129 | Results.propTypes = {
130 | items: ...,
131 | // new prop type for `status` 👇🏾
132 | status: PropTypes.oneOf(['idle', 'pending', 'resolved']).isRequired,
133 | }
134 | ```
135 |
136 | > NOTE: You can change the value to `wait()` in [`api.js`](./api.js) to be higher to simulate a slow API response.
137 |
138 | Display the loading indicator as well as the previous results using a Fragment:
139 |
140 | ```js
141 | const Results = ({ items, status }) => {
142 | const isLoading = status === 'idle' || status === 'pending'
143 |
144 | return (
145 | <>
146 | {isLoading && (
147 |
148 | Loading new results...
149 |
150 | )}
151 | {items.length > 0 && (
152 | ...
153 | )}
154 | >
155 | )
156 | }
157 | ```
158 |
159 | ---
160 |
161 | ### With `useReducer()`
162 |
163 | In [`useGiphy.js`](./useGiphy.js), refactor the two `useState()` calls for `status` & `results` to a single call to `useReducer()`:
164 |
165 | ```js
166 | const INITIAL_STATE = {
167 | status: 'idle',
168 | results: [],
169 | }
170 |
171 | // 👇🏾 brand new reducer
172 | const reducer = (state, action) => {
173 | switch (action.type) {
174 | case 'started': {
175 | return {
176 | ...state,
177 | status: 'pending',
178 | }
179 | }
180 | case 'success': {
181 | return {
182 | ...state,
183 | status: 'resolved',
184 | results: action.results,
185 | }
186 | }
187 | default: {
188 | // In case we mis-type an action!
189 | throw new Error(`Unhandled action type: ${action.type}`)
190 | }
191 | }
192 | }
193 |
194 | const useGiphy = () => {
195 | const [searchParams, setSearchParams] = useState({})
196 | // 👇🏾 new reducer state
197 | const [state, dispatch] = useReducer(reducer, INITIAL_STATE)
198 |
199 | useEffect(() => {
200 | const fetchResults = async () => {
201 | try {
202 | // 👇🏾 dispatch action instead of directly setting state
203 | dispatch({ type: 'started' })
204 |
205 | const apiResponse = await getResults(searchParams)
206 |
207 | // 👇🏾 dispatched action will set two state properties
208 | dispatch({ type: 'success', results: apiResults })
209 | } catch (err) {
210 | console.error(err)
211 | }
212 | }
213 |
214 | fetchResults()
215 | }, [searchParams])
216 |
217 | return [state, setSearchParams]
218 | }
219 | ```
220 |
221 | ## 💡 Exercises
222 |
223 | - Display an error state if the API fails to successfully return
224 | - Can display with the previous results, but should hide when new results are requested
225 | - Use the `'rejected'` status
226 | - 🔑 _HINT:_ `throw new Error('Fake error!')` in [`api.js`](./api.js) to easily simulate this case
227 |
228 | ## 🧠 Elaboration & Feedback
229 |
230 | After you're done with the exercise and before jumping to the next step, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Step+10+-+Loading+States). It will help seal in what you've learned.
231 |
232 | ## 👉🏾 Next Step
233 |
234 | Go to [Final Quiz!](../quiz/).
235 |
236 | ## 📕 Resources
237 |
238 | - [`useReducer` API reference](https://reactjs.org/docs/hooks-reference.html#usereducer)
239 | - [Fragments](https://reactjs.org/docs/fragments.html)
240 | - [Stop using isLoading booleans](https://kentcdodds.com/blog/stop-using-isloading-booleans)
241 | - [Don't Sync State. Derive It](https://kentcdodds.com/blog/dont-sync-state-derive-it)
242 | - [Should I useState or useReducer](https://kentcdodds.com/blog/should-i-usestate-or-usereducer)
243 |
244 | ## ❓ Questions
245 |
246 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
247 |
--------------------------------------------------------------------------------
/src/10-loading-states/Results.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 | import ResultsItem from './ResultsItem'
4 |
5 | const Results = ({ items, status }) => {
6 | const containerEl = useRef(null)
7 | const isLoading = status === 'idle' || status === 'pending'
8 | const isRejected = status === 'rejected'
9 | let message
10 |
11 | if (isLoading) {
12 | message = (
13 |
14 | Loading new results...
15 |
16 | )
17 | } else if (isRejected) {
18 | message = (
19 |
20 |
21 | There was an error retrieving results. Please try again.
22 |
23 |
24 | )
25 | }
26 |
27 | return (
28 | <>
29 | {message}
30 | {items.length > 0 && (
31 | <>
32 |
33 | {items.map((item) => (
34 |
42 | ))}
43 |
44 |
45 | {
49 | containerEl.current.scrollIntoView(true)
50 | }}
51 | >
52 | To top
53 |
54 |
55 | >
56 | )}
57 | >
58 | )
59 | }
60 |
61 | Results.propTypes = {
62 | error: PropTypes.instanceOf(Error),
63 | items: PropTypes.arrayOf(
64 | PropTypes.shape({
65 | id: PropTypes.string.isRequired,
66 | title: PropTypes.string.isRequired,
67 | url: PropTypes.string.isRequired,
68 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
69 | previewUrl: PropTypes.string.isRequired,
70 | }),
71 | ).isRequired,
72 | status: PropTypes.oneOf(['idle', 'pending', 'resolved', 'rejected'])
73 | .isRequired,
74 | }
75 |
76 | export default Results
77 |
--------------------------------------------------------------------------------
/src/10-loading-states/ResultsItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => {
5 | return (
6 |
25 | )
26 | }
27 |
28 | ResultsItem.propTypes = {
29 | id: PropTypes.string.isRequired,
30 | title: PropTypes.string.isRequired,
31 | url: PropTypes.string.isRequired,
32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
33 | previewUrl: PropTypes.string.isRequired,
34 | }
35 |
36 | export default ResultsItem
37 |
--------------------------------------------------------------------------------
/src/10-loading-states/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect, useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const RATINGS = [
5 | { value: '', label: 'All' },
6 | { value: 'g', label: 'G' },
7 | { value: 'pg', label: 'PG' },
8 | { value: 'pg-13', label: 'PG-13' },
9 | { value: 'r', label: 'R' },
10 | ]
11 | const LIMITS = [6, 12, 18, 24, 30]
12 |
13 | const SearchForm = ({
14 | initialLimit,
15 | initialRating,
16 | initialSearchQuery,
17 | initialShowInstant,
18 | onChange,
19 | }) => {
20 | const [inputValue, setInputValue] = useState(initialSearchQuery)
21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery)
22 | const [showInstant, setShowInstant] = useState(initialShowInstant)
23 | const [searchRating, setSearchRating] = useState(initialRating)
24 | const [searchLimit, setSearchLimit] = useState(initialLimit)
25 | const realSearchQuery = showInstant ? inputValue : searchQuery
26 | const queryFieldEl = useRef(null)
27 |
28 | useEffect(() => {
29 | onChange({
30 | searchQuery: realSearchQuery,
31 | rating: searchRating,
32 | limit: searchLimit,
33 | })
34 | }, [onChange, realSearchQuery, searchRating, searchLimit])
35 |
36 | const handleSubmit = (e) => {
37 | e.preventDefault()
38 | setSearchQuery(inputValue)
39 |
40 | // focus the query field after submitting
41 | // to make easier to quickly search again
42 | queryFieldEl.current.focus()
43 | }
44 |
45 | return (
46 |
47 |
64 |
76 |
77 |
78 | Choose a rating
79 | {RATINGS.map(({ value, label }) => (
80 |
81 | {
88 | setSearchRating(value)
89 | }}
90 | />
91 | {label}
92 |
93 | ))}
94 |
95 |
96 |
97 | # of Results
98 | setSearchLimit(e.target.value)}
100 | value={searchLimit}
101 | >
102 | {LIMITS.map((limit) => (
103 |
104 | {limit}
105 |
106 | ))}
107 |
108 |
109 |
110 | )
111 | }
112 |
113 | SearchForm.propTypes = {
114 | initialLimit: PropTypes.number,
115 | initialRating: PropTypes.string,
116 | initialSearchQuery: PropTypes.string,
117 | initialShowInstant: PropTypes.bool,
118 | onChange: PropTypes.func.isRequired,
119 | }
120 | SearchForm.defaultProps = {
121 | initialLimit: 12,
122 | initialRating: '',
123 | initialSearchQuery: '',
124 | initialShowInstant: false,
125 | }
126 |
127 | export default SearchForm
128 |
--------------------------------------------------------------------------------
/src/10-loading-states/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | // Fail 1 of 10 times
47 | if (Math.random() * 10 < 1) {
48 | throw new Error('Fake Error!')
49 | }
50 |
51 | const resp = await fetch(
52 | formatUrl(
53 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
54 | {
55 | q: searchQuery,
56 | rating,
57 | limit,
58 | offset,
59 | lang: 'en',
60 | },
61 | ),
62 | )
63 | const data = await resp.json()
64 | const results = data.data.map(({ id, title, url, images, rating }) => ({
65 | id,
66 | title,
67 | url,
68 | rating: rating.toUpperCase(),
69 | previewUrl: images.preview.mp4,
70 | }))
71 |
72 | return { results, total: data.pagination['total_count'] }
73 | }
74 |
--------------------------------------------------------------------------------
/src/10-loading-states/useGiphy.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useReducer } from 'react'
2 | import { getResults } from './api'
3 |
4 | /**
5 | * @typedef {import('./api').SearchParams} SearchParams
6 | * @typedef {import('./api').GiphyResult} GiphyResult
7 | * @typedef {'idle' | 'pending' | 'resolved' | 'rejected'} Status The status of the data
8 | *
9 | * @typedef State
10 | * @property {Status} status
11 | * @property {GiphyResult[]} results
12 | * @property {Error} error
13 | *
14 | * @typedef Action
15 | * @property {'started' | 'success' | 'error'} type
16 | * @property {GiphyResult[]} [results]
17 | * @property {Error} [error]
18 | */
19 |
20 | /**
21 | * @type {State}
22 | */
23 | const INITIAL_STATE = {
24 | status: 'idle',
25 | results: [],
26 | error: null,
27 | }
28 |
29 | /**
30 | * Returns an updated version of `state` based on the `action`
31 | * @param {State} state Current state
32 | * @param {Action} action Action to update state
33 | * @returns {State} Updated state
34 | */
35 | const reducer = (state, action) => {
36 | switch (action.type) {
37 | case 'started': {
38 | return {
39 | ...state,
40 | status: 'pending',
41 | }
42 | }
43 | case 'success': {
44 | return {
45 | ...state,
46 | status: 'resolved',
47 | results: action.results,
48 | }
49 | }
50 | case 'error': {
51 | return {
52 | ...state,
53 | status: 'rejected',
54 | error: action.error,
55 | }
56 | }
57 | default: {
58 | // In case we mis-type an action!
59 | throw new Error(`Unhandled action type: ${action.type}`)
60 | }
61 | }
62 | }
63 |
64 | /**
65 | * @callback SetSearchParams
66 | * @param {SearchParams} searchParams Search parameters
67 | */
68 |
69 | /**
70 | * A custom hook that returns giphy results and a function to search for updated results
71 | * @returns {[State, SetSearchParams]}
72 | */
73 | const useGiphy = () => {
74 | const [searchParams, setSearchParams] = useState({})
75 | const [state, dispatch] = useReducer(reducer, INITIAL_STATE)
76 |
77 | useEffect(() => {
78 | const fetchResults = async () => {
79 | try {
80 | dispatch({ type: 'started' })
81 |
82 | const apiResponse = await getResults(searchParams)
83 |
84 | dispatch({ type: 'success', results: apiResponse.results })
85 | } catch (err) {
86 | dispatch({ type: 'error', error: err })
87 | }
88 | }
89 |
90 | fetchResults()
91 | }, [searchParams])
92 |
93 | return [state, setSearchParams]
94 | }
95 |
96 | export default useGiphy
97 |
--------------------------------------------------------------------------------
/src/end/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import useGiphy from './useGiphy'
3 | import Results from './Results'
4 | import SearchForm from './SearchForm'
5 |
6 | const App = () => {
7 | const [{ status, results, error }, setSearchParams] = useGiphy()
8 |
9 | return (
10 |
11 | Giphy Search!
12 |
13 |
18 |
19 |
20 |
21 |
22 |
23 | This is the app for the{' '}
24 |
29 | React FUNdamentals Workshop with Ben Ilegbodu
30 |
31 | .
32 |
33 |
34 | )
35 | }
36 |
37 | export default App
38 |
--------------------------------------------------------------------------------
/src/end/README.md:
--------------------------------------------------------------------------------
1 | # The End
2 |
3 | Congratulations! 🏆
4 |
5 | You've reached the end of the React FUNdamentals workshop, learning JSX, `useState`, `useEffect`, custom hooks, and so much more!
6 |
7 | Hopefully, you'll get to apply this learning right away at your job or in a side project. But if not, I suggest you **come back to this workshop in a week or two** and review the content. Your brain likes to garbage collect unused knowledge and we want this to stick around! The content is all open-source.
8 |
9 | ## ❤️ Workshop Feedback
10 |
11 | Feedback is a gift. 🎁 Now that you're done with the workshop, I would greatly appreciate your overall feedback on the workshop to help make it even better for the next learners. **[Share your workshop feedback](https://bit.ly/react-fun-ws-feedback)**.
12 |
--------------------------------------------------------------------------------
/src/end/Results.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 | import ResultsItem from './ResultsItem'
4 |
5 | const Results = ({ items, status }) => {
6 | const containerEl = useRef(null)
7 | const isLoading = status === 'idle' || status === 'pending'
8 | const isRejected = status === 'rejected'
9 | let message
10 |
11 | if (isLoading) {
12 | message = (
13 |
14 | Loading new results...
15 |
16 | )
17 | } else if (isRejected) {
18 | message = (
19 |
20 |
21 | There was an error retrieving results. Please try again.
22 |
23 |
24 | )
25 | }
26 |
27 | return (
28 | <>
29 | {message}
30 | {items.length > 0 && (
31 | <>
32 |
33 | {items.map((item) => (
34 |
42 | ))}
43 |
44 |
45 | {
49 | containerEl.current.scrollIntoView(true)
50 | }}
51 | >
52 | To top
53 |
54 |
55 | >
56 | )}
57 | >
58 | )
59 | }
60 |
61 | Results.propTypes = {
62 | error: PropTypes.instanceOf(Error),
63 | items: PropTypes.arrayOf(
64 | PropTypes.shape({
65 | id: PropTypes.string.isRequired,
66 | title: PropTypes.string.isRequired,
67 | url: PropTypes.string.isRequired,
68 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
69 | previewUrl: PropTypes.string.isRequired,
70 | }),
71 | ).isRequired,
72 | status: PropTypes.oneOf(['idle', 'pending', 'resolved', 'rejected'])
73 | .isRequired,
74 | }
75 |
76 | export default Results
77 |
--------------------------------------------------------------------------------
/src/end/ResultsItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const ResultsItem = ({ id, title, url, rating, previewUrl }) => {
5 | return (
6 |
25 | )
26 | }
27 |
28 | ResultsItem.propTypes = {
29 | id: PropTypes.string.isRequired,
30 | title: PropTypes.string.isRequired,
31 | url: PropTypes.string.isRequired,
32 | rating: PropTypes.oneOf(['G', 'PG', 'PG-13', 'R']).isRequired,
33 | previewUrl: PropTypes.string.isRequired,
34 | }
35 |
36 | export default ResultsItem
37 |
--------------------------------------------------------------------------------
/src/end/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useEffect, useRef } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const RATINGS = [
5 | { value: '', label: 'All' },
6 | { value: 'g', label: 'G' },
7 | { value: 'pg', label: 'PG' },
8 | { value: 'pg-13', label: 'PG-13' },
9 | { value: 'r', label: 'R' },
10 | ]
11 | const LIMITS = [6, 12, 18, 24, 30]
12 |
13 | const SearchForm = ({
14 | initialLimit,
15 | initialRating,
16 | initialSearchQuery,
17 | initialShowInstant,
18 | onChange,
19 | }) => {
20 | const [inputValue, setInputValue] = useState(initialSearchQuery)
21 | const [searchQuery, setSearchQuery] = useState(initialSearchQuery)
22 | const [showInstant, setShowInstant] = useState(initialShowInstant)
23 | const [searchRating, setSearchRating] = useState(initialRating)
24 | const [searchLimit, setSearchLimit] = useState(initialLimit)
25 | const realSearchQuery = showInstant ? inputValue : searchQuery
26 | const queryFieldEl = useRef(null)
27 |
28 | useEffect(() => {
29 | onChange({
30 | searchQuery: realSearchQuery,
31 | rating: searchRating,
32 | limit: searchLimit,
33 | })
34 | }, [onChange, realSearchQuery, searchRating, searchLimit])
35 |
36 | const handleSubmit = (e) => {
37 | e.preventDefault()
38 | setSearchQuery(inputValue)
39 |
40 | // focus the query field after submitting
41 | // to make easier to quickly search again
42 | queryFieldEl.current.focus()
43 | }
44 |
45 | return (
46 |
47 |
64 |
76 |
77 |
78 | Choose a rating
79 | {RATINGS.map(({ value, label }) => (
80 |
81 | {
88 | setSearchRating(value)
89 | }}
90 | />
91 | {label}
92 |
93 | ))}
94 |
95 |
96 |
97 | # of Results
98 | setSearchLimit(e.target.value)}
100 | value={searchLimit}
101 | >
102 | {LIMITS.map((limit) => (
103 |
104 | {limit}
105 |
106 | ))}
107 |
108 |
109 |
110 | )
111 | }
112 |
113 | SearchForm.propTypes = {
114 | initialLimit: PropTypes.number,
115 | initialRating: PropTypes.string,
116 | initialSearchQuery: PropTypes.string,
117 | initialShowInstant: PropTypes.bool,
118 | onChange: PropTypes.func.isRequired,
119 | }
120 | SearchForm.defaultProps = {
121 | initialLimit: 12,
122 | initialRating: '',
123 | initialSearchQuery: '',
124 | initialShowInstant: false,
125 | }
126 |
127 | export default SearchForm
128 |
--------------------------------------------------------------------------------
/src/end/api.js:
--------------------------------------------------------------------------------
1 | import { formatUrl } from 'url-lib'
2 |
3 | /**
4 | * Waits the specified amount of time and returns a resolved Promise when done
5 | * @param {number} waitTimeMs The amount of time to wait in milliseconds
6 | * @returns {Promise} Signal that waiting is done
7 | */
8 | const wait = (waitTimeMs = 0) => {
9 | return new Promise((resolve) => {
10 | setTimeout(resolve, waitTimeMs)
11 | })
12 | }
13 |
14 | /**
15 | * @typedef {'' | 'g' | 'pg' | 'pg-13' | 'r'} RatingFiler The MPAA-style rating for a GIF
16 | * @typedef {'G' | 'PG' | 'PG-13' | 'R'} Rating The MPAA-style rating for a GIF
17 | *
18 | * @typedef GiphyResult
19 | * @type {object}
20 | * @property {string} id The GIF's unique ID
21 | * @property {string} title The title that appears on giphy.com for this GIF
22 | * @property {string} url The unique URL for the GIF
23 | * @property {Rating} rating The MPAA-style rating for the GIF
24 | * @property {string} previewUrl The URL for the GIF in .MP4 format
25 | *
26 | * @typedef SearchParams
27 | * @type {object}
28 | * @property {string} [params.searchQuery=''] Search query term or phrase
29 | * @property {RatingFilter} [params.rating=''] Filters results by specified rating. Not specifying a rating, returns all possible ratings
30 | * @property {number} [params.limit=12] The maximum number of images to return
31 | * @property {number} [params.offset=0] Specifies the starting position of the results.
32 | *
33 | * Retrieves a list of giphy image info matching the specified search parameters
34 | * @param {SearchParams} [params] Search parameters
35 | * @returns {{results: GiphyResult[], total: number}}
36 | */
37 | export const getResults = async ({
38 | searchQuery = '',
39 | rating = '',
40 | limit = 12,
41 | offset = 0,
42 | } = {}) => {
43 | // Increase the number below to give the appearance of a slow API response
44 | await wait(500)
45 |
46 | // Fail 1 of 10 times
47 | if (Math.random() * 10 < 1) {
48 | throw new Error('Fake Error!')
49 | }
50 |
51 | const resp = await fetch(
52 | formatUrl(
53 | 'https://api.giphy.com/v1/gifs/search?api_key=7B4oce3a0BmGU5YC22uOFOVg7JJtWcpH',
54 | {
55 | q: searchQuery,
56 | rating,
57 | limit,
58 | offset,
59 | lang: 'en',
60 | },
61 | ),
62 | )
63 | const data = await resp.json()
64 | const results = data.data.map(({ id, title, url, images, rating }) => ({
65 | id,
66 | title,
67 | url,
68 | rating: rating.toUpperCase(),
69 | previewUrl: images.preview.mp4,
70 | }))
71 |
72 | return { results, total: data.pagination['total_count'] }
73 | }
74 |
--------------------------------------------------------------------------------
/src/end/useGiphy.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useReducer } from 'react'
2 | import { getResults } from './api'
3 |
4 | /**
5 | * @typedef {import('./api').SearchParams} SearchParams
6 | * @typedef {import('./api').GiphyResult} GiphyResult
7 | * @typedef {'idle' | 'pending' | 'resolved' | 'rejected'} Status The status of the data
8 | *
9 | * @typedef State
10 | * @property {Status} status
11 | * @property {GiphyResult[]} results
12 | * @property {Error} error
13 | *
14 | * @typedef Action
15 | * @property {'started' | 'success' | 'error'} type
16 | * @property {GiphyResult[]} [results]
17 | * @property {Error} [error]
18 | */
19 |
20 | /**
21 | * @type {State}
22 | */
23 | const INITIAL_STATE = {
24 | status: 'idle',
25 | results: [],
26 | error: null,
27 | }
28 |
29 | /**
30 | * Returns an updated version of `state` based on the `action`
31 | * @param {State} state Current state
32 | * @param {Action} action Action to update state
33 | * @returns {State} Updated state
34 | */
35 | const reducer = (state, action) => {
36 | switch (action.type) {
37 | case 'started': {
38 | return {
39 | ...state,
40 | status: 'pending',
41 | }
42 | }
43 | case 'success': {
44 | return {
45 | ...state,
46 | status: 'resolved',
47 | results: action.results,
48 | }
49 | }
50 | case 'error': {
51 | return {
52 | ...state,
53 | status: 'rejected',
54 | error: action.error,
55 | }
56 | }
57 | default: {
58 | // In case we mis-type an action!
59 | throw new Error(`Unhandled action type: ${action.type}`)
60 | }
61 | }
62 | }
63 |
64 | /**
65 | * @callback SetSearchParams
66 | * @param {SearchParams} searchParams Search parameters
67 | */
68 |
69 | /**
70 | * A custom hook that returns giphy results and a function to search for updated results
71 | * @returns {[State, SetSearchParams]}
72 | */
73 | const useGiphy = () => {
74 | const [searchParams, setSearchParams] = useState({})
75 | const [state, dispatch] = useReducer(reducer, INITIAL_STATE)
76 |
77 | useEffect(() => {
78 | const fetchResults = async () => {
79 | try {
80 | dispatch({ type: 'started' })
81 |
82 | const apiResponse = await getResults(searchParams)
83 |
84 | dispatch({ type: 'success', results: apiResponse.results })
85 | } catch (err) {
86 | dispatch({ type: 'error', error: err })
87 | }
88 | }
89 |
90 | fetchResults()
91 | }, [searchParams])
92 |
93 | return [state, setSearchParams]
94 | }
95 |
96 | export default useGiphy
97 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 1rem;
4 | }
5 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from 'react-dom'
3 | import App from './end/App'
4 |
5 | import './index.css'
6 |
7 | render( , document.getElementById('root'))
8 |
--------------------------------------------------------------------------------
/src/quiz/README.md:
--------------------------------------------------------------------------------
1 | # Final Quiz!
2 |
3 | Until now, we've only been able to see the first page of results from the Giphy API. We need a pagination UI in order to see more results.
4 |
5 | 🏅 The goal of this final step, the quiz, is to solidify your learning by applying it to build your own pagination component.
6 |
7 |
8 | Help! I didn't finish the previous step! 🚨
9 |
10 | If you didn't successfully complete the previous step, you can jump right in by copying the step.
11 |
12 | Complete the [setup instructions](../../README.md#setup) if you have not yet followed them.
13 |
14 | Re-run the setup script, but use the previous step as a starting point:
15 |
16 | ```sh
17 | npm run setup -- src/10-loading-states
18 | ```
19 |
20 | This will also back up your `src/workshop` folder, saving your work.
21 |
22 | Now restart the app:
23 |
24 | ```sh
25 | npm start
26 | ```
27 |
28 | After some initial compiling, a new browser window should open up at http://localhost:3000/, and you should be able to continue on with the tasks below.
29 |
30 |
31 |
32 | ## Exercise
33 |
34 | - Build a `Pagination` component that will allow you to paginate through the giphy results
35 | - `Pagination` has "Previous" & "Next" links/buttons
36 | - Display the ` ` both above and below the ` `
37 | - Use the `offset` property in the call to `getResults()` to request subsequent pages of results
38 | - Use the `total` property in the return value from `getResults()` to calculate how many pages there are
39 | - 🤓 **BONUS:** Disable the "Previous" & "Next" links/buttons when there are no previous/next pages
40 | - 🤓 **BONUS:** Use the Foundation [Pagination](https://get.foundation/sites/docs/pagination.html) as your HTML & CSS to support jumping to specific pages
41 | - Share your `Pagination` component and its use in `App` in a [gist](https://gist.github.com/) on [my AMA](http://www.benmvp.com/ama/)
42 |
43 | ## 🧠 Elaboration & Feedback
44 |
45 | After you're done with the quiz, please fill out the [elaboration & feedback form](https://docs.google.com/forms/d/e/1FAIpQLScRocWvtbrl4XmT5_NRiE8bSK3CMZil-ZQByBAt8lpsurcRmw/viewform?usp=pp_url&entry.1671251225=React+FUNdamentals+Workshop&entry.1984987236=Final+Quiz). It will help seal in what you've learned.
46 |
47 | ## 👉🏾 Next Step
48 |
49 | Go to the [End](../end/).
50 |
51 | ## ❓ Questions
52 |
53 | Got questions? Need further clarification? Feel free to post a question in [Ben Ilegbodu's AMA](http://www.benmvp.com/ama/)!
54 |
--------------------------------------------------------------------------------