diff --git a/.gitignore b/.gitignore index e050326c..9eaee462 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build .eslintcache /playwright-report /test-results +/playground diff --git a/exercises/README.mdx b/exercises/README.mdx index bdf9577a..35814365 100644 --- a/exercises/README.mdx +++ b/exercises/README.mdx @@ -1,5 +1,114 @@ # Advanced React Patterns 🤯 -Welcome to the workshop +> Some sweeeeeeeet patterns 🍭 -TODO: write more stuff +👨‍💼 Hello, my name is Peter the Product Manager. I'm here to help you get +oriented and to give you your assignments for the day! + +Today, you'll learn important testing skills for web app development including: + +- Latest Ref +- Composition +- Compound Components +- Prop Collections and Getters +- State Initializer +- State Reducer +- Control Props + +## Meet the instructor + +👨‍🏫 Hi, I'm your instructor [Kent C. Dodds](https://kentcdodds.com). Here's some +stuff about me: + +- 🏡 Utah +- 👩 👧 👦 👦 👦 🐕 +- 🏢 [kentcdodds.com](https://kentcdodds.com) +- [🐦](https://twitter.com/kentcdodds)/[🐙](https://github.com/kentcdodds) + @kentcdodds +- 🌌 [EpicWeb.dev](https://EpicWeb.dev) +- 🚀 [EpicReact.dev](https://EpicReact.dev) +- 🏆 [testingjavascript.com](https://testingjavascript.com) +- 🥚 [kcd.im/egghead](https://kcd.im/egghead) +- 🥋 [kcd.im/fem](https://kcd.im/fem) +- 💌 [kcd.im/news](https://kcd.im/news) +- 📝 [kcd.im/blog](https://kcd.im/blog) +- 📽 [kcd.im/youtube](https://kcd.im/youtube) +- 🎙 [kcd.im/calls](https://kcd.im/calls) + +## What this workshop is + +- Lots of exercises + +## What this workshop is not + +- Solo +- Lecture + +## Logistics + +### Schedule + +- 😴 Logistics +- 💪 Latest Ref +- 💪 Composition +- 😴 10 Minutes +- 💪 Compound Components +- 💪 Prop Collections and Getters +- 🌮 60 Minutes +- 💪 State Initializer +- 💪 State Reducer +- 😴 10 Minutes +- 💪 Control Props +- 😴 10 Minutes + +### Scripts + +- `npm run start` + +### Asking Questions + +Please do ask! Interrupt me. If you have an unrelated question, please ask on +[office hours](https://kcd.im/office-hours) or +[the call Kent podcast](https://kcd.im/calls). + +### Zoom + +(For a live remote workshop) + +- Help us make this more human by keeping your video on if possible +- Keep microphone muted unless speaking +- Breakout rooms + +### Exercises + +You'll spend all of your time in the `./playground` directory. You'll find the +problem and solution code in the `./exercises` directory, but you shouldn't need +to go there unless you're curious. Here's how that is structured: + +- `exercises/*.*/README.mdx`: Background information +- `exercises/*.*/*.problem.*/README.*.mdx`: Problem Instructions +- `exercises/*.*/*.problem.*`: Exercises with Emoji helpers. +- `exercises/*.*/*.solution.*`: Solved version + +### Emoji + +- **Kody the Koala** 🐨 "Do this" +- **Matthew the Muscle** 💪 "Exercise" +- **Chuck the Checkered Flag** 🏁 "Final" +- **Marty the Money Bag** 💰 "Here's a hint" +- **Olivia the Owl** 🦉 "Pro-tip" +- **Dominic the Document** 📜 "Docs links" +- **Barry the Bomb** 💣 "Remove this code" +- **Peter the Product Manager** 👨‍💼 "Story time" +- **Alfred the Alert** 🚨 "Extra helpful in test errors" +- **Kellie the Co-worker** 🧝‍♀️ "Your co-worker" + +### Workshop Feedback + +Each exercise has an Elaboration and Feedback link. Please fill that out after +the exercise and instruction. + +At the end of the workshop, there's [a survey](/finished). Please fill that out +as well. + +It's a big job and there's lots to do, so, let's get started! diff --git a/package-lock.json b/package-lock.json index 65424032..e1d20516 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,19 +10,19 @@ "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { - "@kentcdodds/workshop-app": "^1.35.0", + "@kentcdodds/workshop-app": "^1.36.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@playwright/test": "^1.33.0", - "@testing-library/dom": "^9.2.0", + "@testing-library/dom": "^9.3.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.1", + "@types/react": "^18.2.6", + "@types/react-dom": "^18.2.4", "cross-env": "^7.0.3", - "eslint": "^8.39.0", + "eslint": "^8.40.0", "eslint-config-react-app": "^7.0.1", "npm-run-all": "^4.1.5", "prettier": "^2.8.8", @@ -2515,14 +2515,14 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", - "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.1", + "espree": "^9.5.2", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -2601,9 +2601,9 @@ "dev": true }, "node_modules/@eslint/js": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.39.0.tgz", - "integrity": "sha512-kf9RB0Fg7NZfap83B3QOqOGg9QmD9yBudqQXzzOtn3i4y7ZUXe5ONeW34Gwi+TxhH4mvj72R1Zc300KUMa9Bng==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2804,9 +2804,9 @@ } }, "node_modules/@kentcdodds/workshop-app": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@kentcdodds/workshop-app/-/workshop-app-1.35.0.tgz", - "integrity": "sha512-TZPOgq7Sc0V7RHupLJ6Xpu5bXOxDCOLKt1Jc0NggfEER87hRBAv3gqthHK2RW3Gb0fY6GfYjF6s8SKtxSHSkYg==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@kentcdodds/workshop-app/-/workshop-app-1.36.0.tgz", + "integrity": "sha512-2ezyAeYGfQr4rUOjGtKj5SKb2u9/M5aTSHoP3OlwrZrN14YR9cnwvp/R0/Dqco2PhdI15OLaSbXSes+HsANKTA==", "dependencies": { "@kentcdodds/md-temp": "^3.2.1", "@radix-ui/react-accordion": "^1.1.1", @@ -3641,9 +3641,9 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "node_modules/@testing-library/dom": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.2.0.tgz", - "integrity": "sha512-xTEnpUKiV/bMyEsE5bT4oYA0x0Z/colMtxzUY8bKyPXBNLn/e0V4ZjBZkEhms0xE4pv9QsPfSRu9AWS4y5wGvA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.0.tgz", + "integrity": "sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", @@ -3874,9 +3874,9 @@ "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", - "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", + "version": "18.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.6.tgz", + "integrity": "sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3884,9 +3884,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.1.tgz", - "integrity": "sha512-8QZEV9+Kwy7tXFmjJrp3XUKQSs9LTnE0KnoUb0YCguWBiNW0Yfb2iBMYZ08WPg35IR6P3Z0s00B15SwZnO26+w==", + "version": "18.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", + "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", "dev": true, "dependencies": { "@types/react": "*" @@ -5750,15 +5750,15 @@ } }, "node_modules/eslint": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.39.0.tgz", - "integrity": "sha512-mwiok6cy7KTW7rBpo05k6+p4YVZByLNjAZ/ACB9DRCu4YDRwjXI01tWHp6KAUWelsBetTxKK/2sHB0vdS8Z2Og==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.39.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -5769,8 +5769,8 @@ "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -6243,9 +6243,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", - "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -6412,14 +6412,14 @@ } }, "node_modules/espree": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", "dev": true, "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" + "eslint-visitor-keys": "^3.4.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -14231,14 +14231,14 @@ "dev": true }, "@eslint/eslintrc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", - "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.1", + "espree": "^9.5.2", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -14299,9 +14299,9 @@ } }, "@eslint/js": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.39.0.tgz", - "integrity": "sha512-kf9RB0Fg7NZfap83B3QOqOGg9QmD9yBudqQXzzOtn3i4y7ZUXe5ONeW34Gwi+TxhH4mvj72R1Zc300KUMa9Bng==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", "dev": true }, "@fal-works/esbuild-plugin-global-externals": { @@ -14469,9 +14469,9 @@ } }, "@kentcdodds/workshop-app": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@kentcdodds/workshop-app/-/workshop-app-1.35.0.tgz", - "integrity": "sha512-TZPOgq7Sc0V7RHupLJ6Xpu5bXOxDCOLKt1Jc0NggfEER87hRBAv3gqthHK2RW3Gb0fY6GfYjF6s8SKtxSHSkYg==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@kentcdodds/workshop-app/-/workshop-app-1.36.0.tgz", + "integrity": "sha512-2ezyAeYGfQr4rUOjGtKj5SKb2u9/M5aTSHoP3OlwrZrN14YR9cnwvp/R0/Dqco2PhdI15OLaSbXSes+HsANKTA==", "requires": { "@kentcdodds/md-temp": "^3.2.1", "@radix-ui/react-accordion": "^1.1.1", @@ -15140,9 +15140,9 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "@testing-library/dom": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.2.0.tgz", - "integrity": "sha512-xTEnpUKiV/bMyEsE5bT4oYA0x0Z/colMtxzUY8bKyPXBNLn/e0V4ZjBZkEhms0xE4pv9QsPfSRu9AWS4y5wGvA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.0.tgz", + "integrity": "sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==", "dev": true, "requires": { "@babel/code-frame": "^7.10.4", @@ -15337,9 +15337,9 @@ "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "@types/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", - "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", + "version": "18.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.6.tgz", + "integrity": "sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==", "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -15347,9 +15347,9 @@ } }, "@types/react-dom": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.1.tgz", - "integrity": "sha512-8QZEV9+Kwy7tXFmjJrp3XUKQSs9LTnE0KnoUb0YCguWBiNW0Yfb2iBMYZ08WPg35IR6P3Z0s00B15SwZnO26+w==", + "version": "18.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", + "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", "dev": true, "requires": { "@types/react": "*" @@ -16679,15 +16679,15 @@ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, "eslint": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.39.0.tgz", - "integrity": "sha512-mwiok6cy7KTW7rBpo05k6+p4YVZByLNjAZ/ACB9DRCu4YDRwjXI01tWHp6KAUWelsBetTxKK/2sHB0vdS8Z2Og==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.39.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -16698,8 +16698,8 @@ "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -17174,20 +17174,20 @@ } }, "eslint-visitor-keys": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", - "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", "dev": true }, "espree": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", "dev": true, "requires": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" + "eslint-visitor-keys": "^3.4.1" } }, "esprima": { diff --git a/package.json b/package.json index cd6cf0b1..1d9cf943 100644 --- a/package.json +++ b/package.json @@ -15,19 +15,19 @@ "npm": ">=8.16.0" }, "dependencies": { - "@kentcdodds/workshop-app": "^1.35.0", + "@kentcdodds/workshop-app": "^1.36.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@playwright/test": "^1.33.0", - "@testing-library/dom": "^9.2.0", + "@testing-library/dom": "^9.3.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.1", + "@types/react": "^18.2.6", + "@types/react-dom": "^18.2.4", "cross-env": "^7.0.3", - "eslint": "^8.39.0", + "eslint": "^8.40.0", "eslint-config-react-app": "^7.0.1", "npm-run-all": "^4.1.5", "prettier": "^2.8.8", diff --git a/playground/README.mdx b/playground/README.mdx index cce3db9c..e5b97994 100644 --- a/playground/README.mdx +++ b/playground/README.mdx @@ -1 +1,97 @@ -# Compound Components +# Latest Ref + +In our exercise, we have a `useDebounce` function that isn't working the way we +want with hooks. We're going to need to "change the default" using the latest +ref pattern. + +`debounce` is a pattern that's often used in user-input fields. For example, if +you've got a signup form where the user can select their username, you probably +want to validate for the user that the username is not taken. You want to do it +when the user's done typing but without requiring them to do anything to trigger +the validation. With a debounced function, you could say when the user stops +typing for 400ms you can trigger the validation. If they start typing again +after only 350ms then you want to start over and wait again until the user +pauses for 400ms. + +In this exercise, the `debounce` function is already written. Even the +`useDebounce` hook is implemented for you. Your job is to implement the latest +ref pattern to fix its behavior. + +Our example here is a counter button that has a debounced increment function. We +want to make it so this works: + +- The step is `1` +- The user clicks the button +- The user updates the step value to `2` +- The user clicks the button again (before the debounce timer completes) +- The debounce timer completes for both clicks +- The count value should be `2` (instead of `1`) + +(Keep in mind, the tests are there to help you know you got it right). + +Before continuing here, please familiarize yourself with the exercise and how +it's implemented... Got it? Great, let's continue. + +Right now, you can play around with two different problems with the way our +exercise is implemented: + +```ts +// option 1: +// ... +const increment = () => setCount(c => c + step) +const debouncedIncrement = useDebounce(increment, 3000) +// ... +``` + +The problem here is `useDebounce` list `increment` in the dependency list for +`useMemo`. For this reason, any time there's a state update, we create a _new_ +debounced version of that function so the `timer` in that debounce function's +closure is different from the previous which means we don't cancel that timeout. +Ultimate this is the bug our users experience: + +- The user clicks the button +- The user updates the step value +- The user clicks the button again +- The first debounce timer completes +- The count value is incremented by the step value at the time the first click + happened +- The second debounce timer completes +- The count value is incremented by the step value at the time the second click + happened + +This is not what we want at all! And the reason it's a problem is because we're +not memoizing the callback that's going into our `useMemo` dependency list. + +So the alternative solution is we could change our `useDebounce` API to require +you pass a memoized callback: + +```ts +// option 2: +// ... +const increment = React.useCallback(() => setCount(c => c + step), [step]) +const debouncedIncrement = useDebounce(increment, 3000) +// ... +``` + +But again, this callback function will be updated when the `step` value changes +which means we'll get another instance of the `debouncedIncrement`. Dah! So the +user experience doesn't actually change with this adjustment _and_ we have a +less fun API. The latest ref pattern will give us a nice API and we'll avoid +this problem. + +I've made the debounce value last `3000ms` to make it easier for you to observe +and test the behavior, but you can feel free to adjust that as you like. The +tests can also help you make sure you've got things working well. + +
+

Files

+ + +
diff --git a/playground/app.tsx b/playground/app.tsx deleted file mode 100644 index b289f904..00000000 --- a/playground/app.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Toggle, ToggleOn, ToggleOff, ToggleButton } from './toggle' - -export function App() { - return ( -
- - The button is on - The button is off -
- -
-
-
- ) -} diff --git a/playground/index.css b/playground/index.css deleted file mode 100644 index fc21ae80..00000000 --- a/playground/index.css +++ /dev/null @@ -1 +0,0 @@ -@import '/switch.styles.css'; diff --git a/playground/index.tsx b/playground/index.tsx index 28b794de..218105b6 100644 --- a/playground/index.tsx +++ b/playground/index.tsx @@ -1,5 +1,60 @@ +import * as React from 'react' import * as ReactDOM from 'react-dom/client' -import { App } from './app' + +function debounce) => void>( + fn: Callback, + delay: number, +) { + let timer: ReturnType | null = null + return (...args: Parameters) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn(...args) + }, delay) + } +} + +function useDebounce) => unknown>( + callback: Callback, + delay: number, +) { + // 🐨 create a latest ref (via useRef and useEffect) here + + // use the latest version of the callback here: + // 💰 you'll need to pass an annonymous function to debounce. Do *not* + // simply change this to `debounce(latestCallbackRef.current, delay)` + // as that won't work. Can you think of why? + return React.useMemo(() => debounce(callback, delay), [callback, delay]) +} + +function App() { + const [step, setStep] = React.useState(1) + const [count, setCount] = React.useState(0) + + // 🦉 feel free to swap these two implementations and see they don't make + // any difference to the user experience + // const increment = React.useCallback(() => setCount(c => c + step), [step]) + const increment = () => setCount(c => c + step) + const debouncedIncrement = useDebounce(increment, 3000) + return ( +
+
+ +
+ +
+ ) +} const rootEl = document.createElement('div') document.body.append(rootEl) diff --git a/playground/toggle.test.tsx b/playground/toggle.test.tsx deleted file mode 100644 index 766920fd..00000000 --- a/playground/toggle.test.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { verifySimpleToggleWithText } from '~/shared/toggle.test' -import '.' - -await verifySimpleToggleWithText() diff --git a/playground/toggle.tsx b/playground/toggle.tsx deleted file mode 100644 index c0df8d0e..00000000 --- a/playground/toggle.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react' -import { Switch } from '~/shared/switch' - -type ToggleValue = { on: boolean; toggle: () => void } -const ToggleContext = React.createContext(undefined) -ToggleContext.displayName = 'ToggleContext' - -export function Toggle({ children }: { children: React.ReactNode }) { - const [on, setOn] = React.useState(false) - const toggle = () => setOn(!on) - - return ( - - {children} - - ) -} - -export function ToggleOn({ children }: { children: React.ReactNode }) { - const { on } = React.useContext(ToggleContext)! - return <>{on ? children : null} -} - -export function ToggleOff({ children }: { children: React.ReactNode }) { - const { on } = React.useContext(ToggleContext)! - return <>{on ? null : children} -} - -export function ToggleButton({ - ...props -}: Omit, 'on' | 'onClick'>) { - const { on, toggle } = React.useContext(ToggleContext)! - return -}