diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..094c32693 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +end_of_line = lf + +[*.py] +indent_size = 4 +max_line_length = 120 + +[*.md] +indent_size = 4 + +[*.yml] +indent_size = 4 + +[*.html] +max_line_length = off + +[*.js] +max_line_length = off + +[*.css] +indent_size = 4 +max_line_length = off + +# Tests can violate line width restrictions in the interest of clarity. +[**/test_*.py] +max_line_length = off diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml deleted file mode 100644 index b312869e4..000000000 --- a/.github/workflows/.hatch-run.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: hatch-run - -on: - workflow_call: - inputs: - job-name: - required: true - type: string - hatch-run: - required: true - type: string - runs-on-array: - required: false - type: string - default: '["ubuntu-latest"]' - python-version-array: - required: false - type: string - default: '["3.x"]' - node-registry-url: - required: false - type: string - default: "" - secrets: - node-auth-token: - required: false - pypi-username: - required: false - pypi-password: - required: false - -jobs: - hatch: - name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} - strategy: - matrix: - python-version: ${{ fromJson(inputs.python-version-array) }} - runs-on: ${{ fromJson(inputs.runs-on-array) }} - runs-on: ${{ matrix.runs-on }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: "14.x" - registry-url: ${{ inputs.node-registry-url }} - - name: Pin NPM Version - run: npm install -g npm@8.19.3 - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install hatch poetry - - name: Run Scripts - env: - NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} - PYPI_USERNAME: ${{ secrets.pypi-username }} - PYPI_PASSWORD: ${{ secrets.pypi-password }} - run: hatch run ${{ inputs.hatch-run }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index af768579c..000000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: check - -on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * 0" - -jobs: - test-py-cov: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "test-py" - lint-py: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "lint-py" - test-py-matrix: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0} {1}" - hatch-run: "test-py --no-cov" - runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version-array: '["3.9", "3.10", "3.11"]' - test-docs: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "test-docs" - test-js: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "{1}" - hatch-run: "test-js" - lint-js: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "{1}" - hatch-run: "lint-js" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index b4f77ee00..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: codeql - -on: - push: - branches: [main] - pull_request: - # The branches below must be a subset of the branches above - branches: [main] - schedule: - - cron: "43 3 * * 3" - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: ["javascript", "python"] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 7337f505b..000000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,30 +0,0 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: deploy-docs - -on: - push: - branches: - - "main" - tags: - - "*" - -jobs: - deploy-documentation: - runs-on: ubuntu-latest - steps: - - name: Check out src from Git - uses: actions/checkout@v2 - - name: Get history and tags for SCM versioning to work - run: | - git fetch --prune --unshallow - git fetch --depth=1 origin +refs/tags/*:refs/tags/* - - name: Login to Heroku Container Registry - run: echo ${{ secrets.HEROKU_API_KEY }} | docker login -u ${{ secrets.HEROKU_EMAIL }} --password-stdin registry.heroku.com - - name: Build Docker Image - run: docker build . --file docs/Dockerfile --tag registry.heroku.com/${{ secrets.HEROKU_APP_NAME }}/web - - name: Push Docker Image - run: docker push registry.heroku.com/${{ secrets.HEROKU_APP_NAME }}/web - - name: Deploy - run: HEROKU_API_KEY=${{ secrets.HEROKU_API_KEY }} heroku container:release web --app ${{ secrets.HEROKU_APP_NAME }} diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 000000000..199bc1766 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,17 @@ +name: Publish Docs +on: + push: + branches: + - new-docs +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: pip install -r docs/requirements.txt + - run: cd docs && mkdocs gh-deploy --force diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index e9271cbd5..000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,20 +0,0 @@ -# This workflows will upload a Javscript Package using NPM to npmjs.org when a release is created -# For more information see: https://docs.github.com/en/actions/guides/publishing-nodejs-packages - -name: publish - -on: - release: - types: [published] - -jobs: - publish: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "publish" - hatch-run: "publish" - node-registry-url: "https://registry.npmjs.org" - secrets: - node-auth-token: ${{ secrets.NODE_AUTH_TOKEN }} - pypi-username: ${{ secrets.PYPI_USERNAME }} - pypi-password: ${{ secrets.PYPI_PASSWORD }} diff --git a/.gitignore b/.gitignore index 20c041e11..788d5a329 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# --- Build Artifacts --- +src/reactpy/static/index.js* +src/reactpy/static/morphdom/ +src/reactpy/static/pyscript/ + # --- Jupyter --- *.ipynb_checkpoints *Untitled*.ipynb @@ -11,8 +16,9 @@ .jupyter # --- Python --- -.venv -venv +.hatch +.venv* +venv* MANIFEST build dist @@ -28,6 +34,7 @@ pip-wheel-metadata .python-version # -- Python Tests --- +.coverage.* *.coverage *.pytest_cache *.mypy_cache @@ -38,4 +45,3 @@ pip-wheel-metadata # --- JS --- node_modules - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d7bd784cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +<!--attr-start--> + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +<!--attr-end--> + +<!-- +Using the following categories, list your changes in this order: + - "Added" for new features. + - "Changed" for changes in existing functionality. + - "Deprecated" for soon-to-be removed features. + - "Removed" for now removed features. + - "Fixed" for any bug fixes. + - "Security" in case of vulnerabilities. + --> + +<!--changelog-start--> + +## [Unreleased] + +- Nothing (yet) + +[unreleased]: https://github.com/reactive-python/reactpy/compare/1.0.0...HEAD diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 5caf76c93..000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2019-2022 Ryan S. Morshead - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..f5423c3d3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +## The MIT License (MIT) + +#### Copyright (c) Reactive Python and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 83241e19a..d24c250a4 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,21 @@ -# <img src="https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg" align="left" height="45"/> ReactPy +Temporary branch being used to rewrite ReactPy's documentation. -<p> - <a href="https://github.com/reactive-python/reactpy/actions"> - <img src="https://github.com/reactive-python/reactpy/workflows/test/badge.svg?event=push"> - </a> - <a href="https://pypi.org/project/reactpy/"> - <img src="https://img.shields.io/pypi/v/reactpy.svg?label=PyPI"> - </a> - <a href="https://github.com/reactive-python/reactpy/blob/main/LICENSE"> - <img src="https://img.shields.io/badge/License-MIT-purple.svg"> - </a> - <a href="https://reactpy.dev/"> - <img src="https://img.shields.io/website?down_message=offline&label=Docs&logo=read-the-docs&logoColor=white&up_message=online&url=https%3A%2F%2Freactpy.dev%2Fdocs%2Findex.html"> - </a> - <a href="https://discord.gg/uNb5P4hA9X"> - <img src="https://img.shields.io/discord/1111078259854168116?label=Discord&logo=discord"> - </a> -</p> +Many of these pages are in progress, and are using the [original ReactJS docs](https://react.dev/learn) as placeholders. +See live preview here: https://reactive-python.github.io/reactpy -[ReactPy](https://reactpy.dev/) is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in [ReactJS](https://reactjs.org/). Designed with simplicity in mind, ReactPy can be used by those without web development experience while also being powerful enough to grow with your ambitions. +In order to set up an environment to develop these docs... -<table align="center"> - <thead> - <tr> - <th colspan="2" style="text-align: center">Supported Backends</th> - <tr> - <th style="text-align: center">Built-in</th> - <th style="text-align: center">External</th> - </tr> - </thead> - <tbody> - <tr> - <td> - <a href="https://reactpy.dev/docs/guides/getting-started/installing-reactpy.html#officially-supported-servers"> - Flask, FastAPI, Sanic, Tornado - </a> - </td> - <td> - <a href="https://github.com/reactive-python/reactpy-django">Django</a>, - <a href="https://github.com/reactive-python/reactpy-jupyter">Jupyter</a>, - <a href="https://github.com/idom-team/idom-dash">Plotly-Dash</a> - </td> - </tr> - </tbody> -</table> +1. Install [Python](https://www.python.org/downloads/) 3.9 or higher +2. Fork and clone this repository +3. _Optional_: Create a Python virtual environment with the following command: `python3 -m venv venv` +4. _Optional_: Activate the virtual environment (this method will vary based on operating system) +5. Install the dependencies with the following command: `pip install -r docs/requirements.txt` +6. Run the following command: `mkdocs serve` +7. Follow the on-screen prompts to view the documentation in your browser +8. You can now edit the markdown files located within `docs/src/` and see the changes in real time -# At a Glance +🚧 : Unfinished tab +🚫 : Tab is blocked from being started due to external factors -To get a rough idea of how to write apps in ReactPy, take a look at this tiny _Hello World_ application. - -```python -from reactpy import component, html, run - -@component -def hello_world(): - return html.h1("Hello, World!") - -run(hello_world) -``` - -# Resources - -Follow the links below to find out more about this project. - -- [Try ReactPy (Jupyter Notebook)](https://mybinder.org/v2/gh/reactive-python/reactpy-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb) -- [Documentation](https://reactpy.dev/) -- [GitHub Discussions](https://github.com/reactive-python/reactpy/discussions) -- [Discord](https://discord.gg/uNb5P4hA9X) -- [Contributor Guide](https://reactpy.dev/docs/about/contributor-guide.html) -- [Code of Conduct](https://github.com/reactive-python/reactpy/blob/main/CODE_OF_CONDUCT.md) +Feel free to PR this branch with any changes you make to the documentation. If you have any questions, feel free to ask in the [Discord server](https://discord.gg/uNb5P4hA9X). diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index ea38eebf8..000000000 --- a/docs/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -build -source/_auto -source/_static/custom.js -source/vdom-json-schema.json diff --git a/docs/Dockerfile b/docs/Dockerfile deleted file mode 100644 index 76a8ad7ee..000000000 --- a/docs/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -FROM python:3.9 - -WORKDIR /app/ - -# Install NodeJS -# -------------- -RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - -RUN apt-get install -yq nodejs build-essential -RUN npm install -g npm@8.5.0 - -# Install Poetry -# -------------- -RUN pip install poetry - -# Create/Activate Python Venv -# --------------------------- -ENV VIRTUAL_ENV=/opt/venv -RUN python3 -m venv $VIRTUAL_ENV -ENV PATH="$VIRTUAL_ENV/bin:$PATH" -RUN pip install --upgrade pip - -# Copy Files -# ---------- -COPY LICENSE ./ -COPY src ./src -COPY docs ./docs -COPY branding ./branding - -# Install and Build Docs -# ---------------------- -WORKDIR /app/docs -RUN poetry install -RUN sphinx-build -v -W -b html source build - -# Define Entrypoint -# ----------------- -ENV PORT 5000 -ENV REACTPY_DEBUG_MODE=1 -ENV REACTPY_CHECK_VDOM_SPEC=0 -CMD python main.py diff --git a/docs/LICENSE.md b/docs/LICENSE.md new file mode 100644 index 000000000..d21a91a91 --- /dev/null +++ b/docs/LICENSE.md @@ -0,0 +1,393 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public licenses. +Notwithstanding, Creative Commons may elect to apply one of its public +licenses to material it publishes and in those instances will be +considered the "Licensor." Except for the limited purpose of indicating +that material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the public +licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 1360bc825..000000000 --- a/docs/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# ReactPy's Documentation - -We provide two main ways to run the docs. Both use -[`nox`](https://pypi.org/project/nox/): - -- `nox -s docs` - displays the docs and rebuilds when files are modified. -- `nox -s docs-in-docker` - builds a docker image and runs the docs from there. - -If any changes to the core of the documentation are made (i.e. to non-`*.rst` files), -then you should run a manual test of the documentation using the `docs_in_docker` -session. - -If you wish to build and run the docs by hand you need to perform two commands, each -being run from the root of the repository: - -- `sphinx-build -b html docs/source docs/build` -- `python scripts/run_docs.py` - -The first command constructs the static HTML and any Javascript. The latter actually -runs the web server that serves the content. diff --git a/docs/docs_app/app.py b/docs/docs_app/app.py deleted file mode 100644 index 3fe4669ff..000000000 --- a/docs/docs_app/app.py +++ /dev/null @@ -1,59 +0,0 @@ -from logging import getLogger -from pathlib import Path - -from sanic import Sanic, response - -from docs_app.examples import get_normalized_example_name, load_examples -from reactpy import component -from reactpy.backend.sanic import Options, configure, use_request -from reactpy.core.types import ComponentConstructor - -THIS_DIR = Path(__file__).parent -DOCS_DIR = THIS_DIR.parent -DOCS_BUILD_DIR = DOCS_DIR / "build" - -REACTPY_MODEL_SERVER_URL_PREFIX = "/_reactpy" - -logger = getLogger(__name__) - - -REACTPY_MODEL_SERVER_URL_PREFIX = "/_reactpy" - - -@component -def Example(): - raw_view_id = use_request().get_args().get("view_id") - view_id = get_normalized_example_name(raw_view_id) - return _get_examples()[view_id]() - - -def _get_examples(): - if not _EXAMPLES: - _EXAMPLES.update(load_examples()) - return _EXAMPLES - - -def reload_examples(): - _EXAMPLES.clear() - _EXAMPLES.update(load_examples()) - - -_EXAMPLES: dict[str, ComponentConstructor] = {} - - -def make_app(name: str): - app = Sanic(name) - - app.static("/docs", str(DOCS_BUILD_DIR)) - - @app.route("/") - async def forward_to_index(_): - return response.redirect("/docs/index.html") - - configure( - app, - Example, - Options(url_prefix=REACTPY_MODEL_SERVER_URL_PREFIX), - ) - - return app diff --git a/docs/docs_app/dev.py b/docs/docs_app/dev.py deleted file mode 100644 index 5d661924d..000000000 --- a/docs/docs_app/dev.py +++ /dev/null @@ -1,104 +0,0 @@ -import asyncio -import os -import threading -import time -import webbrowser - -from sphinx_autobuild.cli import ( - Server, - _get_build_args, - _get_ignore_handler, - find_free_port, - get_builder, - get_parser, -) - -from docs_app.app import make_app, reload_examples -from reactpy.backend.sanic import serve_development_app -from reactpy.testing import clear_reactpy_web_modules_dir - -# these environment variable are used in custom Sphinx extensions -os.environ["REACTPY_DOC_EXAMPLE_SERVER_HOST"] = "127.0.0.1:5555" -os.environ["REACTPY_DOC_STATIC_SERVER_HOST"] = "" - - -def wrap_builder(old_builder): - # This is the bit that we're injecting to get the example components to reload too - - app = make_app("docs_dev_app") - - thread_started = threading.Event() - - def run_in_thread(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - server_started = asyncio.Event() - - async def set_thread_event_when_started(): - await server_started.wait() - thread_started.set() - - loop.run_until_complete( - asyncio.gather( - serve_development_app(app, "127.0.0.1", 5555, server_started), - set_thread_event_when_started(), - ) - ) - - threading.Thread(target=run_in_thread, daemon=True).start() - - thread_started.wait() - - def new_builder(): - clear_reactpy_web_modules_dir() - reload_examples() - old_builder() - - return new_builder - - -def main(): - # Mostly copied from https://github.com/executablebooks/sphinx-autobuild/blob/b54fb08afc5112bfcda1d844a700c5a20cd6ba5e/src/sphinx_autobuild/cli.py - parser = get_parser() - args = parser.parse_args() - - srcdir = os.path.realpath(args.sourcedir) - outdir = os.path.realpath(args.outdir) - if not os.path.exists(outdir): - os.makedirs(outdir) - - server = Server() - - build_args, pre_build_commands = _get_build_args(args) - builder = wrap_builder( - get_builder( - server.watcher, - build_args, - host=args.host, - port=args.port, - pre_build_commands=pre_build_commands, - ) - ) - - ignore_handler = _get_ignore_handler(args) - server.watch(srcdir, builder, ignore=ignore_handler) - for dirpath in args.additional_watched_dirs: - real_dirpath = os.path.realpath(dirpath) - server.watch(real_dirpath, builder, ignore=ignore_handler) - server.watch(outdir, ignore=ignore_handler) - - if not args.no_initial_build: - builder() - - # Find the free port - portn = args.port or find_free_port() - if args.openbrowser is True: - - def opener(): - time.sleep(args.delay) - webbrowser.open(f"http://{args.host}:{args.port}/index.html") - - threading.Thread(target=opener, daemon=True).start() - - server.serve(port=portn, host=args.host, root=outdir) diff --git a/docs/docs_app/examples.py b/docs/docs_app/examples.py deleted file mode 100644 index a71a0b111..000000000 --- a/docs/docs_app/examples.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from io import StringIO -from pathlib import Path -from traceback import format_exc -from typing import Callable - -import reactpy -from reactpy.types import ComponentType - -HERE = Path(__file__) -SOURCE_DIR = HERE.parent.parent / "source" -CONF_FILE = SOURCE_DIR / "conf.py" -RUN_ReactPy = reactpy.run - - -def load_examples() -> Iterator[tuple[str, Callable[[], ComponentType]]]: - for name in all_example_names(): - yield name, load_one_example(name) - - -def all_example_names() -> set[str]: - names = set() - for file in _iter_example_files(SOURCE_DIR): - path = file.parent if file.name == "main.py" else file - names.add("/".join(path.relative_to(SOURCE_DIR).with_suffix("").parts)) - return names - - -def load_one_example(file_or_name: Path | str) -> Callable[[], ComponentType]: - return lambda: ( - # we use a lambda to ensure each instance is fresh - _load_one_example(file_or_name) - ) - - -def get_normalized_example_name( - name: str, relative_to: str | Path | None = SOURCE_DIR -) -> str: - return "/".join( - _get_root_example_path_by_name(name, relative_to).relative_to(SOURCE_DIR).parts - ) - - -def get_main_example_file_by_name( - name: str, relative_to: str | Path | None = SOURCE_DIR -) -> Path: - path = _get_root_example_path_by_name(name, relative_to) - if path.is_dir(): - return path / "main.py" - else: - return path.with_suffix(".py") - - -def get_example_files_by_name( - name: str, relative_to: str | Path | None = SOURCE_DIR -) -> list[Path]: - path = _get_root_example_path_by_name(name, relative_to) - if path.is_dir(): - return [p for p in path.glob("*") if not p.is_dir()] - else: - path = path.with_suffix(".py") - return [path] if path.exists() else [] - - -def _iter_example_files(root: Path) -> Iterator[Path]: - for path in root.iterdir(): - if path.is_dir(): - if not path.name.startswith("_") or path.name == "_examples": - yield from _iter_example_files(path) - elif path.suffix == ".py" and path != CONF_FILE: - yield path - - -def _load_one_example(file_or_name: Path | str) -> ComponentType: - if isinstance(file_or_name, str): - file = get_main_example_file_by_name(file_or_name) - else: - file = file_or_name - - if not file.exists(): - raise FileNotFoundError(str(file)) - - print_buffer = _PrintBuffer() - - def capture_print(*args, **kwargs): - buffer = StringIO() - print(*args, file=buffer, **kwargs) - print_buffer.write(buffer.getvalue()) - - captured_component_constructor = None - - def capture_component(component_constructor): - nonlocal captured_component_constructor - captured_component_constructor = component_constructor - - reactpy.run = capture_component - try: - code = compile(file.read_text(), str(file), "exec") - exec( - code, - { - "print": capture_print, - "__file__": str(file), - "__name__": file.stem, - }, - ) - except Exception: - return _make_error_display(format_exc()) - finally: - reactpy.run = RUN_ReactPy - - if captured_component_constructor is None: - return _make_example_did_not_run(str(file)) - - @reactpy.component - def Wrapper(): - return reactpy.html.div(captured_component_constructor(), PrintView()) - - @reactpy.component - def PrintView(): - text, set_text = reactpy.hooks.use_state(print_buffer.getvalue()) - print_buffer.set_callback(set_text) - return ( - reactpy.html.pre({"class_name": "printout"}, text) - if text - else reactpy.html.div() - ) - - return Wrapper() - - -def _get_root_example_path_by_name(name: str, relative_to: str | Path | None) -> Path: - if not name.startswith("/") and relative_to is not None: - rel_path = Path(relative_to) - rel_path = rel_path.parent if rel_path.is_file() else rel_path - else: - rel_path = SOURCE_DIR - return rel_path.joinpath(*name.split("/")).resolve() - - -class _PrintBuffer: - def __init__(self, max_lines: int = 10): - self._callback = None - self._lines = () - self._max_lines = max_lines - - def set_callback(self, function: Callable[[str], None]) -> None: - self._callback = function - - def getvalue(self) -> str: - return "".join(self._lines) - - def write(self, text: str) -> None: - if len(self._lines) == self._max_lines: - self._lines = self._lines[1:] + (text,) - else: - self._lines += (text,) - if self._callback is not None: - self._callback(self.getvalue()) - - -def _make_example_did_not_run(example_name): - @reactpy.component - def ExampleDidNotRun(): - return reactpy.html.code(f"Example {example_name} did not run") - - return ExampleDidNotRun() - - -def _make_error_display(message): - @reactpy.component - def ShowError(): - return reactpy.html.pre(message) - - return ShowError() diff --git a/docs/docs_app/prod.py b/docs/docs_app/prod.py deleted file mode 100644 index 0acf12432..000000000 --- a/docs/docs_app/prod.py +++ /dev/null @@ -1,14 +0,0 @@ -import os - -from docs_app.app import make_app - -app = make_app("docs_prod_app") - - -def main() -> None: - app.run( - host="0.0.0.0", # noqa: S104 - port=int(os.environ.get("PORT", 5000)), - workers=int(os.environ.get("WEB_CONCURRENCY", 1)), - debug=bool(int(os.environ.get("DEBUG", "0"))), - ) diff --git a/docs/examples/add_react_to_an_existing_project/asgi_component.py b/docs/examples/add_react_to_an_existing_project/asgi_component.py new file mode 100644 index 000000000..ed1a6b25b --- /dev/null +++ b/docs/examples/add_react_to_an_existing_project/asgi_component.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def hello_world(): + return html.div("Hello World") diff --git a/docs/examples/add_react_to_an_existing_project/asgi_configure_jinja.py b/docs/examples/add_react_to_an_existing_project/asgi_configure_jinja.py new file mode 100644 index 000000000..e22212e75 --- /dev/null +++ b/docs/examples/add_react_to_an_existing_project/asgi_configure_jinja.py @@ -0,0 +1,20 @@ +from jinja2 import Environment, FileSystemLoader +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from reactpy.templatetags import ReactPyJinja + +jinja_templates = Jinja2Templates( + env=Environment( + loader=FileSystemLoader("path/to/my_templates"), + extensions=[ReactPyJinja], + ) +) + + +async def example_webpage(request): + return jinja_templates.TemplateResponse(request, "my_template.html") + + +starlette_app = Starlette(routes=[Route("/", example_webpage)]) diff --git a/docs/examples/add_react_to_an_existing_project/asgi_middleware.py b/docs/examples/add_react_to_an_existing_project/asgi_middleware.py new file mode 100644 index 000000000..2e397be02 --- /dev/null +++ b/docs/examples/add_react_to_an_existing_project/asgi_middleware.py @@ -0,0 +1,22 @@ +from jinja2 import Environment, FileSystemLoader +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from reactpy.executors.asgi import ReactPyMiddleware +from reactpy.templatetags import ReactPyJinja + +jinja_templates = Jinja2Templates( + env=Environment( + loader=FileSystemLoader("path/to/my_templates"), + extensions=[ReactPyJinja], + ) +) + + +async def example_webpage(request): + return jinja_templates.TemplateResponse(request, "my_template.html") + + +starlette_app = Starlette(routes=[Route("/", example_webpage)]) +reactpy_app = ReactPyMiddleware(starlette_app, ["my_components.hello_world"]) diff --git a/docs/examples/add_react_to_an_existing_project/asgi_template.html b/docs/examples/add_react_to_an_existing_project/asgi_template.html new file mode 100644 index 000000000..4fe678b8b --- /dev/null +++ b/docs/examples/add_react_to_an_existing_project/asgi_template.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + +<head> + <title>ReactPy in Django</title> +</head> + +<body> + {% component "my_components.hello_world" %} +</body> + +</html> diff --git a/docs/examples/creating_a_react_app/asgi_csr.py b/docs/examples/creating_a_react_app/asgi_csr.py new file mode 100644 index 000000000..506930d33 --- /dev/null +++ b/docs/examples/creating_a_react_app/asgi_csr.py @@ -0,0 +1,7 @@ +from pathlib import Path + +from reactpy.executors.asgi import ReactPyCsr + +my_app = ReactPyCsr( + Path(__file__).parent / "components" / "root.py", initial="Loading..." +) diff --git a/docs/examples/creating_a_react_app/asgi_csr_root.py b/docs/examples/creating_a_react_app/asgi_csr_root.py new file mode 100644 index 000000000..8a6c17c55 --- /dev/null +++ b/docs/examples/creating_a_react_app/asgi_csr_root.py @@ -0,0 +1,6 @@ +from reactpy import component, html + + +@component +def root(): + return html.div("Hello World") diff --git a/docs/examples/creating_a_react_app/asgi_ssr.py b/docs/examples/creating_a_react_app/asgi_ssr.py new file mode 100644 index 000000000..d2e9b72ff --- /dev/null +++ b/docs/examples/creating_a_react_app/asgi_ssr.py @@ -0,0 +1,10 @@ +from reactpy import component, html +from reactpy.executors.asgi import ReactPy + + +@component +def hello_world(): + return html.div("Hello World") + + +my_app = ReactPy(hello_world) diff --git a/docs/examples/quick_start/adding_styles.css b/docs/examples/quick_start/adding_styles.css new file mode 100644 index 000000000..4d02060e1 --- /dev/null +++ b/docs/examples/quick_start/adding_styles.css @@ -0,0 +1,4 @@ +/* In your CSS */ +.avatar { + border-radius: 50%; +} diff --git a/docs/examples/quick_start/adding_styles.py b/docs/examples/quick_start/adding_styles.py new file mode 100644 index 000000000..8e1c7931b --- /dev/null +++ b/docs/examples/quick_start/adding_styles.py @@ -0,0 +1,4 @@ +from reactpy import html + +# start +html.img({"className": "avatar"}) diff --git a/docs/examples/quick_start/conditional_rendering.py b/docs/examples/quick_start/conditional_rendering.py new file mode 100644 index 000000000..d89e097a4 --- /dev/null +++ b/docs/examples/quick_start/conditional_rendering.py @@ -0,0 +1,22 @@ +from reactpy import component, html + + +def admin_panel(): + return [] + + +def login_form(): + return [] + + +is_logged_in = True + + +# start +@component +def my_component(): + if is_logged_in: + content = admin_panel() + else: + content = login_form() + return html.div(content) diff --git a/docs/examples/quick_start/conditional_rendering_logical_and.py b/docs/examples/quick_start/conditional_rendering_logical_and.py new file mode 100644 index 000000000..07e752bba --- /dev/null +++ b/docs/examples/quick_start/conditional_rendering_logical_and.py @@ -0,0 +1,14 @@ +from reactpy import component, html + + +def admin_panel(): + return [] + + +is_logged_in = True + + +# start +@component +def my_component(): + return html.div(is_logged_in and admin_panel()) diff --git a/docs/examples/quick_start/conditional_rendering_ternary.py b/docs/examples/quick_start/conditional_rendering_ternary.py new file mode 100644 index 000000000..2490cb5f9 --- /dev/null +++ b/docs/examples/quick_start/conditional_rendering_ternary.py @@ -0,0 +1,18 @@ +from reactpy import component, html + + +def admin_panel(): + return [] + + +def login_form(): + return [] + + +is_logged_in = True + + +# start +@component +def my_component(): + return html.div(admin_panel() if is_logged_in else login_form()) diff --git a/docs/examples/quick_start/creating_and_nesting_components.py b/docs/examples/quick_start/creating_and_nesting_components.py new file mode 100644 index 000000000..99ff909a2 --- /dev/null +++ b/docs/examples/quick_start/creating_and_nesting_components.py @@ -0,0 +1,14 @@ +from reactpy import component, html + + +@component +def my_button(): + return html.button("I'm a button!") + + +@component +def my_app(): + return html.div( + html.h1("Welcome to my app"), + my_button(), + ) diff --git a/docs/examples/quick_start/displaying_data.css b/docs/examples/quick_start/displaying_data.css new file mode 100644 index 000000000..9f5a392c1 --- /dev/null +++ b/docs/examples/quick_start/displaying_data.css @@ -0,0 +1,7 @@ +.avatar { + border-radius: 50%; +} + +.large { + border: 4px solid gold; +} diff --git a/docs/examples/quick_start/displaying_data.py b/docs/examples/quick_start/displaying_data.py new file mode 100644 index 000000000..3edf62674 --- /dev/null +++ b/docs/examples/quick_start/displaying_data.py @@ -0,0 +1,25 @@ +from reactpy import component, html + +user = { + "name": "Hedy Lamarr", + "image_url": "https://i.imgur.com/yXOvdOSs.jpg", + "image_size": 90, +} + + +@component +def profile(): + return html.div( + html.h3(user["name"]), + html.img( + { + "className": "avatar", + "src": user["image_url"], + "alt": f"Photo of {user['name']}", + "style": { + "width": user["image_size"], + "height": user["image_size"], + }, + } + ), + ) diff --git a/docs/examples/quick_start/my_app.py b/docs/examples/quick_start/my_app.py new file mode 100644 index 000000000..1616d54d1 --- /dev/null +++ b/docs/examples/quick_start/my_app.py @@ -0,0 +1,12 @@ +from reactpy import component, html + +from .my_button import my_button + + +# start +@component +def my_app(): + return html.div( + html.h1("Welcome to my app"), + my_button(), + ) diff --git a/docs/examples/quick_start/my_button.py b/docs/examples/quick_start/my_button.py new file mode 100644 index 000000000..0016632ca --- /dev/null +++ b/docs/examples/quick_start/my_button.py @@ -0,0 +1,7 @@ +from reactpy import component, html + + +# start +@component +def my_button(): + return html.button("I'm a button!") diff --git a/docs/examples/quick_start/rendering_lists.py b/docs/examples/quick_start/rendering_lists.py new file mode 100644 index 000000000..be75f8ce2 --- /dev/null +++ b/docs/examples/quick_start/rendering_lists.py @@ -0,0 +1,23 @@ +from reactpy import component, html + +products = [ + {"title": "Cabbage", "is_fruit": False, "id": 1}, + {"title": "Garlic", "is_fruit": False, "id": 2}, + {"title": "Apple", "is_fruit": True, "id": 3}, +] + + +@component +def shopping_list(): + list_items = [ + html.li( + { + "key": product["id"], + "style": {"color": "magenta" if product["is_fruit"] else "darkgreen"}, + }, + product["title"], + ) + for product in products + ] + + return html.ul(list_items) diff --git a/docs/examples/quick_start/rendering_lists_list_items.py b/docs/examples/quick_start/rendering_lists_list_items.py new file mode 100644 index 000000000..43e108f36 --- /dev/null +++ b/docs/examples/quick_start/rendering_lists_list_items.py @@ -0,0 +1,6 @@ +from reactpy import html + +from .rendering_lists_products import products + +# start +list_items = [html.li({"key": product["id"]}, product["title"]) for product in products] diff --git a/docs/examples/quick_start/rendering_lists_products.py b/docs/examples/quick_start/rendering_lists_products.py new file mode 100644 index 000000000..c2d6a5e31 --- /dev/null +++ b/docs/examples/quick_start/rendering_lists_products.py @@ -0,0 +1,5 @@ +products = [ + {"title": "Cabbage", "id": 1}, + {"title": "Garlic", "id": 2}, + {"title": "Apple", "id": 3}, +] diff --git a/docs/examples/quick_start/responding_to_events.py b/docs/examples/quick_start/responding_to_events.py new file mode 100644 index 000000000..613f11ca2 --- /dev/null +++ b/docs/examples/quick_start/responding_to_events.py @@ -0,0 +1,13 @@ +from reactpy import component, html + + +# start +@component +def my_button(): + def handle_click(event): + print("You clicked me!") + + return html.button( + {"onClick": handle_click}, + "Click me", + ) diff --git a/docs/examples/quick_start/sharing_data_between_components.css b/docs/examples/quick_start/sharing_data_between_components.css new file mode 100644 index 000000000..6081b7ef4 --- /dev/null +++ b/docs/examples/quick_start/sharing_data_between_components.css @@ -0,0 +1,4 @@ +button { + display: block; + margin-bottom: 5px; +} diff --git a/docs/examples/quick_start/sharing_data_between_components.py b/docs/examples/quick_start/sharing_data_between_components.py new file mode 100644 index 000000000..f781bca9b --- /dev/null +++ b/docs/examples/quick_start/sharing_data_between_components.py @@ -0,0 +1,20 @@ +from reactpy import component, html, use_state + + +@component +def my_app(): + count, set_count = use_state(0) + + def handle_click(event): + set_count(count + 1) + + return html.div( + html.h1("Counters that update together"), + my_button(count, handle_click), + my_button(count, handle_click), + ) + + +@component +def my_button(count, on_click): + return html.button({"onClick": on_click}, f"Clicked {count} times") diff --git a/docs/examples/quick_start/sharing_data_between_components_button.py b/docs/examples/quick_start/sharing_data_between_components_button.py new file mode 100644 index 000000000..a9a4911f2 --- /dev/null +++ b/docs/examples/quick_start/sharing_data_between_components_button.py @@ -0,0 +1,7 @@ +from reactpy import component, html + + +# start +@component +def my_button(count, on_click): + return html.button({"onClick": on_click}, f"Clicked {count} times") diff --git a/docs/examples/quick_start/sharing_data_between_components_move_state.py b/docs/examples/quick_start/sharing_data_between_components_move_state.py new file mode 100644 index 000000000..a3c6126c2 --- /dev/null +++ b/docs/examples/quick_start/sharing_data_between_components_move_state.py @@ -0,0 +1,20 @@ +from reactpy import component, html, use_state + +# start +@component +def my_app(): + count, set_count = use_state(0) + + def handle_click(event): + set_count(count + 1) + + return html.div( + html.h1("Counters that update separately"), + my_button(), + my_button(), + ) + + +@component +def my_button(): + # ... we're moving code from here ... diff --git a/docs/examples/quick_start/sharing_data_between_components_props.py b/docs/examples/quick_start/sharing_data_between_components_props.py new file mode 100644 index 000000000..52294076a --- /dev/null +++ b/docs/examples/quick_start/sharing_data_between_components_props.py @@ -0,0 +1,22 @@ +from reactpy import component, html, use_state + + +# start +@component +def my_app(): + count, set_count = use_state(0) + + def handle_click(event): + set_count(count + 1) + + return html.div( + html.h1("Counters that update together"), + my_button(count, handle_click), + my_button(count, handle_click), + ) + # end + + +@component +def my_button(count, on_click): + ... diff --git a/docs/examples/quick_start/updating_the_screen.css b/docs/examples/quick_start/updating_the_screen.css new file mode 100644 index 000000000..6081b7ef4 --- /dev/null +++ b/docs/examples/quick_start/updating_the_screen.css @@ -0,0 +1,4 @@ +button { + display: block; + margin-bottom: 5px; +} diff --git a/docs/examples/quick_start/updating_the_screen.py b/docs/examples/quick_start/updating_the_screen.py new file mode 100644 index 000000000..36d810529 --- /dev/null +++ b/docs/examples/quick_start/updating_the_screen.py @@ -0,0 +1,20 @@ +from reactpy import component, html, use_state + + +@component +def my_app(): + return html.div( + html.h1("Counters that update separately"), + my_button(), + my_button(), + ) + + +@component +def my_button(): + count, set_count = use_state(0) + + def handle_click(event): + set_count(count + 1) + + return html.button({"onClick": handle_click}, f"Clicked {count} times") diff --git a/docs/examples/quick_start/updating_the_screen_event.py b/docs/examples/quick_start/updating_the_screen_event.py new file mode 100644 index 000000000..60699955d --- /dev/null +++ b/docs/examples/quick_start/updating_the_screen_event.py @@ -0,0 +1,12 @@ +from reactpy import component, html, use_state + + +# start +@component +def my_button(): + count, set_count = use_state(0) + + def handle_click(event): + set_count(count + 1) + + return html.button({"onClick": handle_click}, f"Clicked {count} times") diff --git a/docs/examples/quick_start/updating_the_screen_use_state.py b/docs/examples/quick_start/updating_the_screen_use_state.py new file mode 100644 index 000000000..010487c33 --- /dev/null +++ b/docs/examples/quick_start/updating_the_screen_use_state.py @@ -0,0 +1,5 @@ +from reactpy import use_state + +# end + +use_state() diff --git a/docs/examples/quick_start/updating_the_screen_use_state_button.py b/docs/examples/quick_start/updating_the_screen_use_state_button.py new file mode 100644 index 000000000..c331ee6c9 --- /dev/null +++ b/docs/examples/quick_start/updating_the_screen_use_state_button.py @@ -0,0 +1,8 @@ +from reactpy import component, use_state + + +# start +@component +def my_button(): + count, set_count = use_state(0) + # ... diff --git a/docs/examples/responding_to_events/simple_button.py b/docs/examples/responding_to_events/simple_button.py new file mode 100644 index 000000000..7753b4a70 --- /dev/null +++ b/docs/examples/responding_to_events/simple_button.py @@ -0,0 +1,7 @@ +from reactpy import component, html + + +# start +@component +def button(): + return html.button("I don't do anything") diff --git a/docs/examples/responding_to_events/simple_button_event.css b/docs/examples/responding_to_events/simple_button_event.css new file mode 100644 index 000000000..4fc2d52a0 --- /dev/null +++ b/docs/examples/responding_to_events/simple_button_event.css @@ -0,0 +1,3 @@ +button { + margin-right: 10px; +} diff --git a/docs/examples/responding_to_events/simple_button_event.py b/docs/examples/responding_to_events/simple_button_event.py new file mode 100644 index 000000000..df3e170fd --- /dev/null +++ b/docs/examples/responding_to_events/simple_button_event.py @@ -0,0 +1,9 @@ +from reactpy import component, html + + +@component +def button(): + def handle_click(event): + print("You clicked me!") + + return html.button({"onClick": handle_click}, "Click me") diff --git a/docs/examples/thinking_in_react/add_inverse_data_flow.css b/docs/examples/thinking_in_react/add_inverse_data_flow.css new file mode 100644 index 000000000..4be625123 --- /dev/null +++ b/docs/examples/thinking_in_react/add_inverse_data_flow.css @@ -0,0 +1,14 @@ +body { + padding: 5px; +} +label { + display: block; + margin-top: 5px; + margin-bottom: 5px; +} +th { + padding: 4px; +} +td { + padding: 2px; +} diff --git a/docs/examples/thinking_in_react/add_inverse_data_flow.py b/docs/examples/thinking_in_react/add_inverse_data_flow.py new file mode 100644 index 000000000..540bff825 --- /dev/null +++ b/docs/examples/thinking_in_react/add_inverse_data_flow.py @@ -0,0 +1,108 @@ +from reactpy import component, html, use_state + + +@component +def filterable_product_table(products): + filter_text, set_filter_text = use_state("") + in_stock_only, set_in_stock_only = use_state(False) + + return html.div( + search_bar( + filter_text=filter_text, + in_stock_only=in_stock_only, + set_filter_text=set_filter_text, + set_in_stock_only=set_in_stock_only, + ), + product_table( + products=products, filter_text=filter_text, in_stock_only=in_stock_only + ), + ) + + +@component +def product_category_row(category): + return html.tr( + html.th({"colspan": 2}, category), + ) + + +@component +def product_row(product): + if product["stocked"]: + name = product["name"] + else: + name = html.span({"style": {"color": "red"}}, product["name"]) + + return html.tr( + html.td(name), + html.td(product["price"]), + ) + + +@component +def product_table(products, filter_text, in_stock_only): + rows = [] + last_category = None + + for product in products: + if filter_text.lower() not in product["name"].lower(): + continue + if in_stock_only and not product["stocked"]: + continue + if product["category"] != last_category: + rows.append( + product_category_row(product["category"], key=product["category"]) + ) + rows.append(product_row(product, key=product["name"])) + last_category = product["category"] + + return html.table( + html.thead( + html.tr( + html.th("Name"), + html.th("Price"), + ), + ), + html.tbody(rows), + ) + + +@component +def search_bar(filter_text, in_stock_only, set_filter_text, set_in_stock_only): + return html.form( + html.input( + { + "type": "text", + "value": filter_text, + "placeholder": "Search...", + "onChange": lambda event: set_filter_text(event["target"]["value"]), + } + ), + html.label( + html.input( + { + "type": "checkbox", + "checked": in_stock_only, + "onChange": lambda event: set_in_stock_only( + event["target"]["checked"] + ), + } + ), + "Only show products in stock", + ), + ) + + +PRODUCTS = [ + {"category": "Fruits", "price": "$1", "stocked": True, "name": "Apple"}, + {"category": "Fruits", "price": "$1", "stocked": True, "name": "Dragonfruit"}, + {"category": "Fruits", "price": "$2", "stocked": False, "name": "Passionfruit"}, + {"category": "Vegetables", "price": "$2", "stocked": True, "name": "Spinach"}, + {"category": "Vegetables", "price": "$4", "stocked": False, "name": "Pumpkin"}, + {"category": "Vegetables", "price": "$1", "stocked": True, "name": "Peas"}, +] + + +@component +def app(): + return filterable_product_table(PRODUCTS) diff --git a/docs/examples/thinking_in_react/build_a_static_version_in_react.css b/docs/examples/thinking_in_react/build_a_static_version_in_react.css new file mode 100644 index 000000000..5b8624389 --- /dev/null +++ b/docs/examples/thinking_in_react/build_a_static_version_in_react.css @@ -0,0 +1,15 @@ +body { + padding: 5px; +} +label { + display: block; + margin-top: 5px; + margin-bottom: 5px; +} +th { + padding-top: 10px; +} +td { + padding: 2px; + padding-right: 40px; +} diff --git a/docs/examples/thinking_in_react/build_a_static_version_in_react.py b/docs/examples/thinking_in_react/build_a_static_version_in_react.py new file mode 100644 index 000000000..3c8c46761 --- /dev/null +++ b/docs/examples/thinking_in_react/build_a_static_version_in_react.py @@ -0,0 +1,60 @@ +from reactpy import component, html + + +@component +def product_category_row(category): + return html.tr(html.th({"colSpan": "2"}, category)) + + +@component +def product_row(product): + if product["stocked"]: + name = product["name"] + else: + name = html.span({"style": {"color": "red"}}, product["name"]) + return html.tr(html.td(name), html.td(product["price"])) + + +@component +def product_table(products): + rows = [] + last_category = None + for product in products: + if product["category"] != last_category: + rows.append( + product_category_row(product["category"], key=product["category"]) + ) + rows.append(product_row(product, key=product["name"])) + last_category = product["category"] + + return html.table( + html.thead(html.tr(html.th("Name"), html.th("Price"))), html.tbody(rows) + ) + + +@component +def search_bar(): + return html.form( + html.input({"type": "text", "placeholder": "Search..."}), + html.label(html.input({"type": "checkbox"}), "Only show products in stock"), + ) + + +@component +def filterable_product_table(products): + return html.div(search_bar(), product_table(products)) + + +PRODUCTS = [ + {"category": "Fruits", "price": "$1", "stocked": True, "name": "Apple"}, + {"category": "Fruits", "price": "$1", "stocked": True, "name": "Dragonfruit"}, + {"category": "Fruits", "price": "$2", "stocked": False, "name": "Passionfruit"}, + {"category": "Vegetables", "price": "$2", "stocked": True, "name": "Spinach"}, + {"category": "Vegetables", "price": "$4", "stocked": False, "name": "Pumpkin"}, + {"category": "Vegetables", "price": "$1", "stocked": True, "name": "Peas"}, +] + + +@component +def app(): + return filterable_product_table(PRODUCTS) diff --git a/docs/examples/thinking_in_react/error_example.py b/docs/examples/thinking_in_react/error_example.py new file mode 100644 index 000000000..e1ae45c3c --- /dev/null +++ b/docs/examples/thinking_in_react/error_example.py @@ -0,0 +1,19 @@ +from reactpy import component, html + + +# start +@component +def search_bar(filter_text, in_stock_only): + return html.form( + html.input( + { + "type": "text", + "value": filter_text, + "placeholder": "Search...", + } + ), + html.p( + html.input({"type": "checkbox", "checked": in_stock_only}), + "Only show products in stock", + ), + ) diff --git a/docs/examples/thinking_in_react/event_handlers.py b/docs/examples/thinking_in_react/event_handlers.py new file mode 100644 index 000000000..81eb99611 --- /dev/null +++ b/docs/examples/thinking_in_react/event_handlers.py @@ -0,0 +1,17 @@ +from reactpy import html + +filter_text = "" + + +def set_filter_text(value): ... + + +# start +html.input( + { + "type": "text", + "value": filter_text, + "placeholder": "Search...", + "onChange": lambda event: set_filter_text(event["target"]["value"]), + } +) diff --git a/docs/examples/thinking_in_react/identify_where_your_state_should_live.css b/docs/examples/thinking_in_react/identify_where_your_state_should_live.css new file mode 100644 index 000000000..b811252a1 --- /dev/null +++ b/docs/examples/thinking_in_react/identify_where_your_state_should_live.css @@ -0,0 +1,14 @@ +body { + padding: 5px; +} +label { + display: block; + margin-top: 5px; + margin-bottom: 5px; +} +th { + padding-top: 5px; +} +td { + padding: 2px; +} diff --git a/docs/examples/thinking_in_react/identify_where_your_state_should_live.py b/docs/examples/thinking_in_react/identify_where_your_state_should_live.py new file mode 100644 index 000000000..f81a2cd03 --- /dev/null +++ b/docs/examples/thinking_in_react/identify_where_your_state_should_live.py @@ -0,0 +1,88 @@ +from reactpy import component, html, use_state + + +@component +def filterable_product_table(products): + filter_text, set_filter_text = use_state("") + in_stock_only, set_in_stock_only = use_state(False) + + return html.div( + search_bar(filter_text=filter_text, in_stock_only=in_stock_only), + product_table( + products=products, filter_text=filter_text, in_stock_only=in_stock_only + ), + ) + + +@component +def product_category_row(category): + return html.tr( + html.th({"colspan": 2}, category), + ) + + +@component +def product_row(product): + if product["stocked"]: + name = product["name"] + else: + name = html.span({"style": {"color": "red"}}, product["name"]) + + return html.tr( + html.td(name), + html.td(product["price"]), + ) + + +@component +def product_table(products, filter_text, in_stock_only): + rows = [] + last_category = None + + for product in products: + if filter_text.lower() not in product["name"].lower(): + continue + if in_stock_only and not product["stocked"]: + continue + if product["category"] != last_category: + rows.append( + product_category_row(product["category"], key=product["category"]) + ) + rows.append(product_row(product, key=product["name"])) + last_category = product["category"] + + return html.table( + html.thead( + html.tr( + html.th("Name"), + html.th("Price"), + ), + ), + html.tbody(rows), + ) + + +@component +def search_bar(filter_text, in_stock_only): + return html.form( + html.input({"type": "text", "value": filter_text, "placeholder": "Search..."}), + html.label( + html.input({"type": "checkbox", "checked": in_stock_only}), + "Only show products in stock", + ), + ) + + +PRODUCTS = [ + {"category": "Fruits", "price": "$1", "stocked": True, "name": "Apple"}, + {"category": "Fruits", "price": "$1", "stocked": True, "name": "Dragonfruit"}, + {"category": "Fruits", "price": "$2", "stocked": False, "name": "Passionfruit"}, + {"category": "Vegetables", "price": "$2", "stocked": True, "name": "Spinach"}, + {"category": "Vegetables", "price": "$4", "stocked": False, "name": "Pumpkin"}, + {"category": "Vegetables", "price": "$1", "stocked": True, "name": "Peas"}, +] + + +@component +def app(): + return filterable_product_table(PRODUCTS) diff --git a/docs/examples/thinking_in_react/set_state_props.py b/docs/examples/thinking_in_react/set_state_props.py new file mode 100644 index 000000000..821249933 --- /dev/null +++ b/docs/examples/thinking_in_react/set_state_props.py @@ -0,0 +1,20 @@ +from reactpy import component, hooks, html + + +def search_bar(**_kws): ... + + +# start +@component +def filterable_product_table(products): + filter_text, set_filter_text = hooks.use_state("") + in_stock_only, set_in_stock_only = hooks.use_state(False) + + return html.div( + search_bar( + filter_text=filter_text, + in_stock_only=in_stock_only, + set_filter_text=set_filter_text, + set_in_stock_only=set_in_stock_only, + ) + ) diff --git a/docs/examples/thinking_in_react/start_with_the_mockup.json b/docs/examples/thinking_in_react/start_with_the_mockup.json new file mode 100644 index 000000000..f49a528b0 --- /dev/null +++ b/docs/examples/thinking_in_react/start_with_the_mockup.json @@ -0,0 +1,38 @@ +[ + { + "category": "Fruits", + "price": "$1", + "stocked": true, + "name": "Apple" + }, + { + "category": "Fruits", + "price": "$1", + "stocked": true, + "name": "Dragonfruit" + }, + { + "category": "Fruits", + "price": "$2", + "stocked": false, + "name": "Passionfruit" + }, + { + "category": "Vegetables", + "price": "$2", + "stocked": true, + "name": "Spinach" + }, + { + "category": "Vegetables", + "price": "$4", + "stocked": false, + "name": "Pumpkin" + }, + { + "category": "Vegetables", + "price": "$1", + "stocked": true, + "name": "Peas" + } +] diff --git a/docs/examples/thinking_in_react/use_state.py b/docs/examples/thinking_in_react/use_state.py new file mode 100644 index 000000000..437554974 --- /dev/null +++ b/docs/examples/thinking_in_react/use_state.py @@ -0,0 +1,8 @@ +from reactpy import component, use_state + + +# start +@component +def filterable_product_table(products): + filter_text, set_filter_text = use_state("") + in_stock_only, set_in_stock_only = use_state(False) diff --git a/docs/examples/thinking_in_react/use_state_with_components.py b/docs/examples/thinking_in_react/use_state_with_components.py new file mode 100644 index 000000000..2964929ea --- /dev/null +++ b/docs/examples/thinking_in_react/use_state_with_components.py @@ -0,0 +1,17 @@ +from reactpy import html + +filter_text = "" +in_stock_only = False +products = () + +def search_bar(**_kw): + ... + +def product_table(**_kw): + ... + +# start +html.div( + search_bar(filter_text=filter_text, in_stock_only=in_stock_only), + product_table(products=products, filter_text=filter_text, in_stock_only=in_stock_only), +) diff --git a/docs/examples/tutorial_tic_tac_toe/setup_for_the_tutorial.css b/docs/examples/tutorial_tic_tac_toe/setup_for_the_tutorial.css new file mode 100644 index 000000000..e3efaf92a --- /dev/null +++ b/docs/examples/tutorial_tic_tac_toe/setup_for_the_tutorial.css @@ -0,0 +1,42 @@ +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} diff --git a/docs/examples/tutorial_tic_tac_toe/setup_for_the_tutorial.py b/docs/examples/tutorial_tic_tac_toe/setup_for_the_tutorial.py new file mode 100644 index 000000000..e0893d6b4 --- /dev/null +++ b/docs/examples/tutorial_tic_tac_toe/setup_for_the_tutorial.py @@ -0,0 +1,7 @@ +from reactpy import component, html + + +# start +@component +def square(): + return html.button({"className": "square"}, "X") diff --git a/docs/examples/tutorial_tic_tac_toe/tic_tac_toe.css b/docs/examples/tutorial_tic_tac_toe/tic_tac_toe.css new file mode 100644 index 000000000..87876ec02 --- /dev/null +++ b/docs/examples/tutorial_tic_tac_toe/tic_tac_toe.css @@ -0,0 +1,42 @@ +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} diff --git a/docs/examples/tutorial_tic_tac_toe/tic_tac_toe.py b/docs/examples/tutorial_tic_tac_toe/tic_tac_toe.py new file mode 100644 index 000000000..5eed504d1 --- /dev/null +++ b/docs/examples/tutorial_tic_tac_toe/tic_tac_toe.py @@ -0,0 +1,115 @@ +from copy import deepcopy + +from reactpy import component, html, use_state + + +@component +def square(value, on_square_click): + return html.button( + {"className": "square", "onClick": on_square_click}, + value, + ) + + +@component +def board(x_is_next, squares, on_play): + def handle_click(i): + def inner(event): + """ + Due to a quirk of Python, if your event handler needs args other than + `event`, you will need to create a wrapper function as seen above. + Ref: https://pylint.readthedocs.io/en/stable/user_guide/messages/warning/cell-var-from-loop.html + """ + if calculate_winner(squares) or squares[i]: + return + + next_squares = squares.copy() + next_squares[i] = "X" if x_is_next else "O" + on_play(next_squares) + + return inner + + winner = calculate_winner(squares) + status = ( + f"Winner: {winner}" if winner else "Next player: " + ("X" if x_is_next else "O") + ) + + return html._( + html.div({"className": "status"}, status), + html.div( + {"className": "board-row"}, + square(squares[0], handle_click(0)), + square(squares[1], handle_click(1)), + square(squares[2], handle_click(2)), + ), + html.div( + {"className": "board-row"}, + square(squares[3], handle_click(3)), + square(squares[4], handle_click(4)), + square(squares[5], handle_click(5)), + ), + html.div( + {"className": "board-row"}, + square(squares[6], handle_click(6)), + square(squares[7], handle_click(7)), + square(squares[8], handle_click(8)), + ), + ) + + +@component +def game(): + history, set_history = use_state([[None] * 9]) + current_move, set_current_move = use_state(0) + x_is_next = current_move % 2 == 0 + current_squares = history[current_move] + + def handle_play(next_squares): + next_history = deepcopy(history[: current_move + 1]) + next_history.append(next_squares) + set_history(next_history) + set_current_move(len(next_history) - 1) + + def jump_to(next_move): + return lambda _event: set_current_move(next_move) + + moves = [] + for move, _squares in enumerate(history): + description = f"Go to move #{move}" if move > 0 else "Go to game start" + + moves.append( + html.li( + {"key": move}, + html.button({"onClick": jump_to(move)}, description), + ) + ) + + return html.div( + {"className": "game"}, + html.div( + {"className": "game-board"}, + board(x_is_next, current_squares, handle_play), + ), + html.div({"className": "game-info"}, html.ol(moves)), + ) + + +def calculate_winner(squares): + lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ] + for line in lines: + a, b, c = line + if not squares: + continue + if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]: + return squares[a] + + return None diff --git a/docs/main.py b/docs/main.py deleted file mode 100644 index e3181f393..000000000 --- a/docs/main.py +++ /dev/null @@ -1,9 +0,0 @@ -import sys - -from docs_app import dev, prod - -if __name__ == "__main__": - if len(sys.argv) == 1: - prod.main() - else: - dev.main() diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 000000000..77af61224 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,224 @@ +--- +nav: + - Home: index.md + - Get Started: + - Quick Start: + - learn/quick-start.md + - "Tutorial: Tic-Tac-Toe 🚧": learn/tutorial-tic-tac-toe.md + - Thinking in React: learn/thinking-in-react.md + - Installation: + - Creating a Standalone React App: learn/creating-a-react-app.md + - Add React to an Existing Project: learn/add-react-to-an-existing-project.md + - Setup: + - Editor Setup: learn/editor-setup.md + # - ReactPy Developer Tools 🚫: learn/react-developer-tools.md + - Tools, Modules, and Packages 🚧: learn/extra-tools-and-packages.md + # - More Tutorials: + # - "Tutorial: React Bootstrap 🚫": learn/tutorial-react-bootstrap.md + # - "Tutorial: Material UI 🚫": learn/tutorial-material-ui.md + - Learn: + - Describing the UI: + - Your First Component 🚧: learn/your-first-component.md + - Importing and Exporting Components 🚧: learn/importing-and-exporting-components.md + # - Writing Markup with PSX 🚫: learn/writing-markup-with-psx.md + # - Python in PSX with Curly Braces 🚫: learn/python-in-psx-with-curly-braces.md + - Passing Props to a Component 🚧: learn/passing-props-to-a-component.md + - Conditional Rendering 🚧: learn/conditional-rendering.md + - Rendering Lists 🚧: learn/rendering-lists.md + - Keeping Components Pure 🚧: learn/keeping-components-pure.md + - Adding Interactivity: + - Responding to Events 🚧: learn/responding-to-events.md + - "State: A Component's Memory 🚧": learn/state-a-components-memory.md + - Render and Commit 🚧: learn/render-and-commit.md + - State as a Snapshot 🚧: learn/state-as-a-snapshot.md + - Queueing a Series of State Updates 🚧: learn/queueing-a-series-of-state-updates.md + - Updating Objects in State 🚧: learn/updating-objects-in-state.md + - Updating Arrays in State 🚧: learn/updating-arrays-in-state.md + - Managing State: + - Reacting to Input with State 🚧: learn/reacting-to-input-with-state.md + - Choosing the State Structure 🚧: learn/choosing-the-state-structure.md + - Sharing State Between Components 🚧: learn/sharing-state-between-components.md + - Preserving and Resetting State 🚧: learn/preserving-and-resetting-state.md + - Extracting State Logic into a Reducer 🚧: learn/extracting-state-logic-into-a-reducer.md + - Passing Data Deeply with Context 🚧: learn/passing-data-deeply-with-context.md + - Scaling Up with Reducer and Context 🚧: learn/scaling-up-with-reducer-and-context.md + - Escape Hatches: + - Referencing Values with Refs 🚧: learn/referencing-values-with-refs.md + - Manipulating the DOM with Refs 🚧: learn/manipulating-the-dom-with-refs.md + - Synchronizing with Effects 🚧: learn/synchronizing-with-effects.md + - You Might Not Need an Effect 🚧: learn/you-might-not-need-an-effect.md + - Lifecycle of Reactive Effects 🚧: learn/lifecycle-of-reactive-effects.md + - Separating Events from Effects 🚧: learn/separating-events-from-effects.md + - Removing Effect Dependencies 🚧: learn/removing-effect-dependencies.md + - Reusing Logic with Custom Hooks 🚧: learn/reusing-logic-with-custom-hooks.md + - Communicating Data Between Server and Client 🚧: learn/communicate-data-between-server-and-client.md + - Convert Between VDOM and HTML 🚧: learn/convert-between-vdom-and-html.md + - VDOM Mutations 🚧: learn/vdom-mutations.md + - Creating VDOM Event Handlers 🚧: learn/creating-vdom-event-handlers.md + - Creating HTML Tags 🚧: learn/creating-html-tags.md + - Creating Backends 🚧: learn/creating-backends.md + - Manually Register a Client 🚧: learn/manually-register-a-client.md + - Reference: + - Overview: reference/overview.md + - Hooks: + - Use State 🚧: reference/use-state.md + - Use Effect 🚧: reference/use-effect.md + - Use Async Effect 🚧: reference/use-async-effect.md + - Use Context 🚧: reference/use-context.md + - Use Reducer 🚧: reference/use-reducer.md + - Use Callback 🚧: reference/use-callback.md + - Use Memo 🚧: reference/use-memo.md + - Use Ref 🚧: reference/use-ref.md + - Use Debug Value 🚧: reference/use-debug-value.md + - Use Connection 🚧: reference/use-connection.md + - Use Scope 🚧: reference/use-scope.md + - Use Location 🚧: reference/use-location.md + - HTML Tags: + - Common Props 🚧: reference/common-props.md + - Usage 🚧: reference/usage.md + - Executors: + - ReactPy: reference/reactpy.md + - ReactPyCsr 🚧: reference/reactpy-csr.md + - ReactPyMiddleware 🚧: reference/reactpy-middleware.md + - Rules of React: + - Overview 🚧: reference/rules-of-react.md + - Components and Hooks must be pure 🚧: reference/components-and-hooks-must-be-pure.md + - React calls Components and Hooks 🚧: reference/react-calls-components-and-hooks.md + - Rules of Hooks 🚧: reference/rules-of-hooks.md + # - Template Tags: + # - Jinja 🚧: reference/jinja.md + - Protocol Structure 🚧: reference/protocol-structure.md + - Client API 🚧: reference/client-api.md + - About: + - Changelog 🚧: about/changelog.md + - Community 🚧: about/community.md + - Running Tests 🚧: about/running-tests.md + - Contributing Code 🚧: about/code.md + - Contributing Documentation 🚧: about/docs.md + - Licenses 🚧: about/licenses.md + +theme: + name: material + custom_dir: overrides + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/white-balance-sunny + name: Switch to light mode + primary: red # We use red to indicate that something is unthemed + accent: red + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + primary: white + accent: red + features: + # - navigation.instant + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - content.code.copy + - search.highlight + icon: + repo: fontawesome/brands/github + admonition: + note: fontawesome/solid/note-sticky + logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + +markdown_extensions: + - toc: + permalink: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + linenums: true + - pymdownx.superfences + - pymdownx.details + - pymdownx.inlinehilite + - admonition + - attr_list + - md_in_html + - pymdownx.keys + +plugins: + - search + - include-markdown + - git-authors + # - section-index + - minify: + minify_html: true + minify_js: true + minify_css: true + cache_safe: true + - git-revision-date-localized: + fallback_to_build_date: true + # - spellcheck: + # known_words: dictionary.txt + # allow_unicode: no + - mkdocstrings: + default_handler: python + handlers: + python: + paths: ["../"] + import: + - https://installer.readthedocs.io/en/stable/objects.inv + options: + signature_crossrefs: true + scoped_crossrefs: true + relative_crossrefs: true + modernize_annotations: true + unwrap_annotated: true + find_stubs_package: true + show_root_members_full_path: true + show_bases: false + show_source: false + show_root_toc_entry: false + show_labels: false + show_symbol_type_toc: true + show_symbol_type_heading: true + show_object_full_path: true + heading_level: 3 + +extra: + generator: false + version: + provider: mike + +extra_javascript: + - assets/js/main.js + +extra_css: + - assets/css/main.css + - assets/css/button.css + - assets/css/admonition.css + - assets/css/banner.css + - assets/css/sidebar.css + - assets/css/navbar.css + - assets/css/table-of-contents.css + - assets/css/code.css + - assets/css/footer.css + - assets/css/home.css + +watch: + - "../docs" + - ../README.md + - ../CHANGELOG.md + - ../LICENSE.md + - "../src" + +site_name: ReactPy +site_author: Archmonger +site_description: It's React, but in Python. +copyright: '©<div id="year"> </div> <script> document.getElementById("year").innerHTML = new Date().getFullYear(); </script>Reactive Python and affiliates.<div class="legal-footer-right">This project has no affiliation to ReactJS or Meta Platforms, Inc.</div>' +repo_url: https://github.com/reactive-python/reactpy +site_url: https://reactive-python.github.io/reactpy +repo_name: ReactPy +edit_uri: edit/main/docs/src +docs_dir: src diff --git a/docs/overrides/home.html b/docs/overrides/home.html new file mode 100644 index 000000000..77446c7c8 --- /dev/null +++ b/docs/overrides/home.html @@ -0,0 +1,135 @@ +<!-- TODO: Use markdown code blocks whenever mkdocs starts supported markdown embeds --> +{% extends "main.html" %} + +<!-- Set the content block to empty --> +{% block content %}{% endblock %} + +<!-- Override the tabs block to create the homepage --> +{% block tabs %} +<style> + /* Variables */ + [data-md-color-scheme="slate"] { + --row-stripe-bg-color: conic-gradient(from 90deg at -10% 100%, + #2b303b 0deg, + #2b303b 90deg, + #16181d 1turn); + --row-bg-color: conic-gradient(from -90deg at 110% 100%, #2b303b 0deg, #16181d 90deg, #16181d 1turn); + --stripe-border-color: rgba(246, 247, 249, 0.1); + --code-block-filter: none; + --home-tabbed-set-bg-color: #1f1f1f; + } + + [data-md-color-scheme="default"] { + --row-stripe-bg-color: conic-gradient(from 90deg at -10% 100%, + #bcc1cd 0deg, + #bcc1cd 90deg, + #fff 1turn); + --row-bg-color: var(--row-stripe-bg-color); + --stripe-border-color: rgba(35, 39, 47, 0.1); + --code-block-filter: invert(1) contrast(1.3) hue-rotate(180deg) saturate(2); + --code-tab-color: rgb(246 247 249); + --home-tabbed-set-bg-color: #fff; + } + + /* Application header should be static for the landing page */ + .md-header { + position: initial; + } + + /* Hide markdown area */ + .md-main__inner { + margin: 0; + } + + .md-content { + display: none; + } +</style> +<section class="home md-typeset"> + <div class="row first"> + <img src="https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg" + alt="ReactPy Logo" class="home-logo"> + <h1>{{ config.site_name }}</h1> + <p>{{ config.site_description }}</p> + <div class="home-btns"> + <a href="{{ page.next_page.url | url }}" class="md-button md-button--primary"> + Get Started + </a> + <a href="{{ 'reference/components/' | url }}" class="md-button"> + API Reference + </a> + <a href="{{ 'about/changelog/' | url }}" class="md-button"> + Changelog + </a> + </div> + </div> + + <div class="row stripe"> + <h1>Create user interfaces from components</h1> + <p class="md-grid"> + ReactPy lets you build user interfaces out of individual pieces called components. Create your own ReactPy + components like <code>thumbnail</code>, <code>like_button</code>, and <code>video</code>. Then combine + them into entire screens, pages, and apps. + </p> + <div class="example-container"> + {% with image="create-user-interfaces.png", class="pop-left" %} + {% include "homepage_examples/code_block.html" %} + {% endwith %} + {% include "homepage_examples/create_user_interfaces_demo.html" %} + </div> + <p> + Whether you work on your own or with thousands of other developers, using React feels the same. It is + designed to let you seamlessly combine components written by independent people, teams, and + organizations. + </p> + </div> + + <div class="row"> + <h1>Write components with pure Python code</h1> + <p> + ReactPy components are Python functions. Want to show some content conditionally? Use an + <code>if</code> statement. Displaying a list? Try using + <a href="https://www.w3schools.com/python/python_lists_comprehension.asp">list comprehension</a>. + Learning ReactPy is learning programming. + </p> + <div class="example-container"> + {% with image="write-components-with-python.png", class="pop-left" %} + {% include "homepage_examples/code_block.html" %} + {% endwith %} + {% include "homepage_examples/write_components_with_python_demo.html" %} + + </div> + </div> + + <div class="row stripe"> + <h1>Add interactivity wherever you need it</h1> + <p> + ReactPy components receive data and return what should appear on the screen. You can pass them new data in + response to an interaction, like when the user types into an input. ReactPy will then update the screen to + match the new data. + </p> + <div class="example-container"> + {% with image="add-interactivity.png" %} + {% include "homepage_examples/code_block.html" %} + {% endwith %} + {% include "homepage_examples/add_interactivity_demo.html" %} + </div> + <p> + You don't have to build your whole page in ReactPy. Add React to your existing HTML page, and render + interactive ReactPy components anywhere on it. + </p> + </div> + + <div class="row"> + <h1>Go full-stack with a framework</h1> + <p> + ReactPy is a library. It lets you put components together, but it doesn't prescribe how to do routing and + data fetching. To build an entire app with ReactPy, we recommend a backend framework like + <a href="https://www.djangoproject.com/">Django</a> or <a href="https://www.starlette.io/">Starlette</a>. + </p> + <a href="{{ page.next_page.url | url }}" class="md-button md-button--primary"> + Get Started + </a> + </div> +</section> +{% endblock %} diff --git a/docs/overrides/homepage_examples/add_interactivity.py b/docs/overrides/homepage_examples/add_interactivity.py new file mode 100644 index 000000000..9a7bf76f1 --- /dev/null +++ b/docs/overrides/homepage_examples/add_interactivity.py @@ -0,0 +1,29 @@ +# ruff: noqa: INP001 +from reactpy import component, html, use_state + + +def filter_videos(*_, **__): + return [] + + +def search_input(*_, **__): ... + + +def video_list(*_, **__): ... + + +@component +def searchable_video_list(videos): + search_text, set_search_text = use_state("") + found_videos = filter_videos(videos, search_text) + + return html._( + search_input( + {"onChange": lambda event: set_search_text(event["target"]["value"])}, + value=search_text, + ), + video_list( + videos=found_videos, + empty_heading=f"No matches for “{search_text}”", + ), + ) diff --git a/docs/overrides/homepage_examples/add_interactivity_demo.html b/docs/overrides/homepage_examples/add_interactivity_demo.html new file mode 100644 index 000000000..ec8c1e4db --- /dev/null +++ b/docs/overrides/homepage_examples/add_interactivity_demo.html @@ -0,0 +1,172 @@ +<div class="demo pop-right"> + <div class="white-bg"> + + <div class="browser-navbar"> + <div class="browser-nav-url"> + <svg class="text-tertiary me-1 opacity-60" width="12" height="12" viewBox="0 0 44 44" fill="none" + xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M22 4C17.0294 4 13 8.0294 13 13V16H12.3103C10.5296 16 8.8601 16.8343 8.2855 18.5198C7.6489 20.387 7 23.4148 7 28C7 32.5852 7.6489 35.613 8.2855 37.4802C8.8601 39.1657 10.5296 40 12.3102 40H31.6897C33.4704 40 35.1399 39.1657 35.7145 37.4802C36.3511 35.613 37 32.5852 37 28C37 23.4148 36.3511 20.387 35.7145 18.5198C35.1399 16.8343 33.4704 16 31.6897 16H31V13C31 8.0294 26.9706 4 22 4ZM25 16V13C25 11.3431 23.6569 10 22 10C20.3431 10 19 11.3431 19 13V16H25Z" + fill="currentColor"></path> + </svg> + example.com/videos.html + </div> + </div> + + <div class="browser-viewport"> + <div class="search-header"> + <h1>Searchable Videos</h1> + <p>Type a search query below.</p> + <div class="search-bar"> + <svg width="1em" height="1em" viewBox="0 0 20 20" class="text-gray-30 w-4"> + <path + d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z" + stroke="currentColor" fill="none" stroke-width="2" fill-rule="evenodd" stroke-linecap="round" + stroke-linejoin="round"></path> + </svg> + <input type="text" placeholder="Search"> + </div> + </div> + + <h2>5 Videos</h2> + + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>ReactPy: The Documentary</h3> + <p>From web library to taco delivery service</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>Code using Worst Practices</h3> + <p>Harriet Potter (2013)</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>Introducing ReactPy Foriegn</h3> + <p>Tim Cooker (2015)</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>Introducing ReactPy Cooks</h3> + <p>Soap Boat and Dinosaur Dan (2018)</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>Introducing Quantum Components</h3> + <p>Isaac Asimov and Lauren-kun (2020)</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + <p class="no-match"></p> + </div> + + <script> + document + .querySelector(".search-bar input") + .addEventListener("keyup", function () { + let titles = document.querySelectorAll(".browser-viewport .vid-text"); + let search = this.value.toLowerCase(); + let numVids = 0; + for (let i = 0; i < titles.length; i++) { + let title = + titles[i].querySelector("h3").innerText.toLowerCase() + + titles[i].querySelector("p").innerText.toLowerCase(); + if (search.length == 0) { + titles[i].parentElement.style.display = ""; + numVids++; + } else if (title.indexOf(search) > -1) { + titles[i].parentElement.style.display = ""; + numVids++; + } else { + titles[i].parentElement.style.display = "none"; + } + } + document.querySelector(".browser-viewport h2").innerText = + numVids + " Videos"; + + if (search && numVids == 0) { + document.querySelector(".browser-viewport .no-match").innerText = `No matches for “${search}”`; + } else { + document.querySelector(".browser-viewport .no-match").innerText = ""; + } + }); + </script> + </div> +</div> diff --git a/docs/overrides/homepage_examples/code_block.html b/docs/overrides/homepage_examples/code_block.html new file mode 100644 index 000000000..27b362046 --- /dev/null +++ b/docs/overrides/homepage_examples/code_block.html @@ -0,0 +1,7 @@ +<div class="tabbed-set tabbed-alternate {{ class }}"> + <input checked="checked" type="radio" /> + <div class="tabbed-labels"><label>app.py</label></div> + <div class="tabbed-content"> + <img src="assets/images/{{ image }}"> + </div> +</div> diff --git a/docs/overrides/homepage_examples/create_user_interfaces.py b/docs/overrides/homepage_examples/create_user_interfaces.py new file mode 100644 index 000000000..7878aa6b5 --- /dev/null +++ b/docs/overrides/homepage_examples/create_user_interfaces.py @@ -0,0 +1,21 @@ +# ruff: noqa: INP001 +from reactpy import component, html + + +def thumbnail(*_, **__): ... + + +def like_button(*_, **__): ... + + +@component +def video(data): + return html.div( + thumbnail(data), + html.a( + {"href": data.url}, + html.h3(data.title), + html.p(data.description), + ), + like_button(data), + ) diff --git a/docs/overrides/homepage_examples/create_user_interfaces_demo.html b/docs/overrides/homepage_examples/create_user_interfaces_demo.html new file mode 100644 index 000000000..9ec25437e --- /dev/null +++ b/docs/overrides/homepage_examples/create_user_interfaces_demo.html @@ -0,0 +1,24 @@ +<div class="demo"> + <div class="white-bg"> + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>My video</h3> + <p>Video description</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + </div> +</div> diff --git a/docs/overrides/homepage_examples/write_components_with_python.py b/docs/overrides/homepage_examples/write_components_with_python.py new file mode 100644 index 000000000..5993046c9 --- /dev/null +++ b/docs/overrides/homepage_examples/write_components_with_python.py @@ -0,0 +1,19 @@ +# ruff: noqa: INP001 +from reactpy import component, html + + +def video(*_, **__): ... + + +@component +def video_list(videos, empty_heading): + count = len(videos) + heading = empty_heading + if count > 0: + noun = "Videos" if count > 1 else "Video" + heading = f"{count} {noun}" + + return html.section( + html.h2(heading), + [video(x, key=x.id) for x in videos], + ) diff --git a/docs/overrides/homepage_examples/write_components_with_python_demo.html b/docs/overrides/homepage_examples/write_components_with_python_demo.html new file mode 100644 index 000000000..779f7abbe --- /dev/null +++ b/docs/overrides/homepage_examples/write_components_with_python_demo.html @@ -0,0 +1,65 @@ +<div class="demo pop-right"> + <div class="white-bg"> + <h2>3 Videos</h2> + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>First video</h3> + <p>Video description</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>Second video</h3> + <p>Video description</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + <div class="vid-row"> + <div class="vid-thumbnail"> + <svg width="36" height="36" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z" + fill="rgb(123 123 123 / 50%)"></path> + </svg> + </div> + <div class="vid-text"> + <h3>Third video</h3> + <p>Video description</p> + </div> + <button class="like-btn"> + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z" + fill="black" fill-rule="evenodd" clip-rule="evenodd" onclick="t"></path> + </svg> + </button> + </div> + </div> +</div> diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 000000000..c63ca9e71 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block content %} +{{ super() }} + +{% if git_page_authors %} +<div class="md-source-date"> + <small> + Authors: {{ git_page_authors | default('enable mkdocs-git-authors-plugin') }} + </small> +</div> +{% endif %} +{% endblock %} + +{% block outdated %} +You're not viewing the latest release. +<a href="{{ '../' ~ base_url }}"> + <strong>Click here to go to latest.</strong> +</a> +{% endblock %} diff --git a/docs/poetry.lock b/docs/poetry.lock deleted file mode 100644 index 8e1daef24..000000000 --- a/docs/poetry.lock +++ /dev/null @@ -1,2269 +0,0 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. - -[[package]] -name = "aiofiles" -version = "23.1.0" -description = "File support for asyncio." -optional = false -python-versions = ">=3.7,<4.0" -files = [ - {file = "aiofiles-23.1.0-py3-none-any.whl", hash = "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2"}, - {file = "aiofiles-23.1.0.tar.gz", hash = "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635"}, -] - -[[package]] -name = "alabaster" -version = "0.7.13" -description = "A configurable sidebar-enabled Sphinx theme" -optional = false -python-versions = ">=3.6" -files = [ - {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, - {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, -] - -[[package]] -name = "anyio" -version = "3.7.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.7" -files = [ - {file = "anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0"}, - {file = "anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce"}, -] - -[package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -doc = ["Sphinx (>=6.1.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme", "sphinxcontrib-jquery"] -test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (<0.22)"] - -[[package]] -name = "asgiref" -version = "3.7.2" -description = "ASGI specs, helper code, and adapters" -optional = false -python-versions = ">=3.7" -files = [ - {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, - {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} - -[package.extras] -tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] - -[[package]] -name = "babel" -version = "2.12.1" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, - {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, -] - -[[package]] -name = "beautifulsoup4" -version = "4.12.2" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"}, - {file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "certifi" -version = "2023.5.7" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.1.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, - {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, -] - -[[package]] -name = "click" -version = "8.1.3" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "colorlog" -version = "6.7.0" -description = "Add colours to the output of Python's logging module." -optional = false -python-versions = ">=3.6" -files = [ - {file = "colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662"}, - {file = "colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -development = ["black", "flake8", "mypy", "pytest", "types-colorama"] - -[[package]] -name = "contourpy" -version = "1.0.7" -description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false -python-versions = ">=3.8" -files = [ - {file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d"}, - {file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab"}, - {file = "contourpy-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8acf74b5d383414401926c1598ed77825cd530ac7b463ebc2e4f46638f56cce6"}, - {file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c71fdd8f1c0f84ffd58fca37d00ca4ebaa9e502fb49825484da075ac0b0b803"}, - {file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e9486bf1bb979d95d5cffed40689cb595abb2b841f2991fc894b3452290e8"}, - {file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f4d8941a9564cda3f7fa6a6cd9b32ec575830780677932abdec7bcb61717b0"}, - {file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e20e5a1908e18aaa60d9077a6d8753090e3f85ca25da6e25d30dc0a9e84c2c6"}, - {file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a877ada905f7d69b2a31796c4b66e31a8068b37aa9b78832d41c82fc3e056ddd"}, - {file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6381fa66866b0ea35e15d197fc06ac3840a9b2643a6475c8fff267db8b9f1e69"}, - {file = "contourpy-1.0.7-cp310-cp310-win32.whl", hash = "sha256:3c184ad2433635f216645fdf0493011a4667e8d46b34082f5a3de702b6ec42e3"}, - {file = "contourpy-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:3caea6365b13119626ee996711ab63e0c9d7496f65641f4459c60a009a1f3e80"}, - {file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed33433fc3820263a6368e532f19ddb4c5990855e4886088ad84fd7c4e561c71"}, - {file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38e2e577f0f092b8e6774459317c05a69935a1755ecfb621c0a98f0e3c09c9a5"}, - {file = "contourpy-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ae90d5a8590e5310c32a7630b4b8618cef7563cebf649011da80874d0aa8f414"}, - {file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130230b7e49825c98edf0b428b7aa1125503d91732735ef897786fe5452b1ec2"}, - {file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58569c491e7f7e874f11519ef46737cea1d6eda1b514e4eb5ac7dab6aa864d02"}, - {file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d43960d809c4c12508a60b66cb936e7ed57d51fb5e30b513934a4a23874fae"}, - {file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:152fd8f730c31fd67fe0ffebe1df38ab6a669403da93df218801a893645c6ccc"}, - {file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9056c5310eb1daa33fc234ef39ebfb8c8e2533f088bbf0bc7350f70a29bde1ac"}, - {file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a9d7587d2fdc820cc9177139b56795c39fb8560f540bba9ceea215f1f66e1566"}, - {file = "contourpy-1.0.7-cp311-cp311-win32.whl", hash = "sha256:4ee3ee247f795a69e53cd91d927146fb16c4e803c7ac86c84104940c7d2cabf0"}, - {file = "contourpy-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:5caeacc68642e5f19d707471890f037a13007feba8427eb7f2a60811a1fc1350"}, - {file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad"}, - {file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0f9d350b639db6c2c233d92c7f213d94d2e444d8e8fc5ca44c9706cf72193772"}, - {file = "contourpy-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e96a08b62bb8de960d3a6afbc5ed8421bf1a2d9c85cc4ea73f4bc81b4910500f"}, - {file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:031154ed61f7328ad7f97662e48660a150ef84ee1bc8876b6472af88bf5a9b98"}, - {file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e9ebb4425fc1b658e13bace354c48a933b842d53c458f02c86f371cecbedecc"}, - {file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb8f6d08ca7998cf59eaf50c9d60717f29a1a0a09caa46460d33b2924839dbd"}, - {file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6c180d89a28787e4b73b07e9b0e2dac7741261dbdca95f2b489c4f8f887dd810"}, - {file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b8d587cc39057d0afd4166083d289bdeff221ac6d3ee5046aef2d480dc4b503c"}, - {file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:769eef00437edf115e24d87f8926955f00f7704bede656ce605097584f9966dc"}, - {file = "contourpy-1.0.7-cp38-cp38-win32.whl", hash = "sha256:62398c80ef57589bdbe1eb8537127321c1abcfdf8c5f14f479dbbe27d0322e66"}, - {file = "contourpy-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:57119b0116e3f408acbdccf9eb6ef19d7fe7baf0d1e9aaa5381489bc1aa56556"}, - {file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30676ca45084ee61e9c3da589042c24a57592e375d4b138bd84d8709893a1ba4"}, - {file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e927b3868bd1e12acee7cc8f3747d815b4ab3e445a28d2e5373a7f4a6e76ba1"}, - {file = "contourpy-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:366a0cf0fc079af5204801786ad7a1c007714ee3909e364dbac1729f5b0849e5"}, - {file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ba9bb365446a22411f0673abf6ee1fea3b2cf47b37533b970904880ceb72f3"}, - {file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b0bf0c30d432278793d2141362ac853859e87de0a7dee24a1cea35231f0d50"}, - {file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7281244c99fd7c6f27c1c6bfafba878517b0b62925a09b586d88ce750a016d2"}, - {file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b6d0f9e1d39dbfb3977f9dd79f156c86eb03e57a7face96f199e02b18e58d32a"}, - {file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f6979d20ee5693a1057ab53e043adffa1e7418d734c1532e2d9e915b08d8ec2"}, - {file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5dd34c1ae752515318224cba7fc62b53130c45ac6a1040c8b7c1a223c46e8967"}, - {file = "contourpy-1.0.7-cp39-cp39-win32.whl", hash = "sha256:c5210e5d5117e9aec8c47d9156d1d3835570dd909a899171b9535cb4a3f32693"}, - {file = "contourpy-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:60835badb5ed5f4e194a6f21c09283dd6e007664a86101431bf870d9e86266c4"}, - {file = "contourpy-1.0.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ce41676b3d0dd16dbcfabcc1dc46090aaf4688fd6e819ef343dbda5a57ef0161"}, - {file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a011cf354107b47c58ea932d13b04d93c6d1d69b8b6dce885e642531f847566"}, - {file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31a55dccc8426e71817e3fe09b37d6d48ae40aae4ecbc8c7ad59d6893569c436"}, - {file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69f8ff4db108815addd900a74df665e135dbbd6547a8a69333a68e1f6e368ac2"}, - {file = "contourpy-1.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efe99298ba37e37787f6a2ea868265465410822f7bea163edcc1bd3903354ea9"}, - {file = "contourpy-1.0.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a1e97b86f73715e8670ef45292d7cc033548266f07d54e2183ecb3c87598888f"}, - {file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc331c13902d0f50845099434cd936d49d7a2ca76cb654b39691974cb1e4812d"}, - {file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24847601071f740837aefb730e01bd169fbcaa610209779a78db7ebb6e6a7051"}, - {file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abf298af1e7ad44eeb93501e40eb5a67abbf93b5d90e468d01fc0c4451971afa"}, - {file = "contourpy-1.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:64757f6460fc55d7e16ed4f1de193f362104285c667c112b50a804d482777edd"}, - {file = "contourpy-1.0.7.tar.gz", hash = "sha256:d8165a088d31798b59e91117d1f5fc3df8168d8b48c4acc10fc0df0d0bdbcc5e"}, -] - -[package.dependencies] -numpy = ">=1.16" - -[package.extras] -bokeh = ["bokeh", "chromedriver", "selenium"] -docs = ["furo", "sphinx-copybutton"] -mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"] -test = ["Pillow", "matplotlib", "pytest"] -test-no-images = ["pytest"] - -[[package]] -name = "cycler" -version = "0.11.0" -description = "Composable style cycles" -optional = false -python-versions = ">=3.6" -files = [ - {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"}, - {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, -] - -[[package]] -name = "docutils" -version = "0.17.1" -description = "Docutils -- Python Documentation Utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.1.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "fastapi" -version = "0.96.0" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.7" -files = [ - {file = "fastapi-0.96.0-py3-none-any.whl", hash = "sha256:b8e11fe81e81eab4e1504209917338e0b80f783878a42c2b99467e5e1019a1e9"}, - {file = "fastapi-0.96.0.tar.gz", hash = "sha256:71232d47c2787446991c81c41c249f8a16238d52d779c0e6b43927d3773dbe3c"}, -] - -[package.dependencies] -pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" -starlette = ">=0.27.0,<0.28.0" - -[package.extras] -all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] -doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"] -test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.7)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.7.0.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] - -[[package]] -name = "fastjsonschema" -version = "2.17.1" -description = "Fastest Python implementation of JSON schema" -optional = false -python-versions = "*" -files = [ - {file = "fastjsonschema-2.17.1-py3-none-any.whl", hash = "sha256:4b90b252628ca695280924d863fe37234eebadc29c5360d322571233dc9746e0"}, - {file = "fastjsonschema-2.17.1.tar.gz", hash = "sha256:f4eeb8a77cef54861dbf7424ac8ce71306f12cbb086c45131bcba2c6a4f726e3"}, -] - -[package.extras] -devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] - -[[package]] -name = "flask" -version = "2.1.3" -description = "A simple framework for building complex web applications." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Flask-2.1.3-py3-none-any.whl", hash = "sha256:9013281a7402ad527f8fd56375164f3aa021ecfaff89bfe3825346c24f87e04c"}, - {file = "Flask-2.1.3.tar.gz", hash = "sha256:15972e5017df0575c3d6c090ba168b6db90259e620ac8d7ea813a396bad5b6cb"}, -] - -[package.dependencies] -click = ">=8.0" -importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} -itsdangerous = ">=2.0" -Jinja2 = ">=3.0" -Werkzeug = ">=2.0" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - -[[package]] -name = "flask-cors" -version = "3.0.10" -description = "A Flask extension adding a decorator for CORS support" -optional = false -python-versions = "*" -files = [ - {file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"}, - {file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"}, -] - -[package.dependencies] -Flask = ">=0.9" -Six = "*" - -[[package]] -name = "flask-sock" -version = "0.6.0" -description = "WebSocket support for Flask" -optional = false -python-versions = ">=3.6" -files = [ - {file = "flask-sock-0.6.0.tar.gz", hash = "sha256:435cf81bb497ac7622cd1dda554fbfa3e369e629daea0a1d21b73a24f1bd6229"}, - {file = "flask_sock-0.6.0-py3-none-any.whl", hash = "sha256:593fffb186928080a5b5b03d717efc56dac2d5ed690ce6bfff333b3597a2f518"}, -] - -[package.dependencies] -flask = ">=2" -simple-websocket = ">=0.5.1" - -[[package]] -name = "fonttools" -version = "4.39.4" -description = "Tools to manipulate font files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fonttools-4.39.4-py3-none-any.whl", hash = "sha256:106caf6167c4597556b31a8d9175a3fdc0356fdcd70ab19973c3b0d4c893c461"}, - {file = "fonttools-4.39.4.zip", hash = "sha256:dba8d7cdb8e2bac1b3da28c5ed5960de09e59a2fe7e63bb73f5a59e57b0430d2"}, -] - -[package.extras] -all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.0.0)", "xattr", "zopfli (>=0.1.4)"] -graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres", "scipy"] -lxml = ["lxml (>=4.0,<5)"] -pathops = ["skia-pathops (>=0.5.0)"] -plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.23.0)"] -symfont = ["sympy"] -type1 = ["xattr"] -ufo = ["fs (>=2.2.0,<3)"] -unicode = ["unicodedata2 (>=15.0.0)"] -woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] - -[[package]] -name = "furo" -version = "2022.4.7" -description = "A clean customisable Sphinx documentation theme." -optional = false -python-versions = ">=3.6" -files = [ - {file = "furo-2022.4.7-py3-none-any.whl", hash = "sha256:7f3e3d2fb977483590f8ecb2c2cd511bd82661b79c18efb24de9558bc9cdf2d7"}, - {file = "furo-2022.4.7.tar.gz", hash = "sha256:96204ab7cd047e4b6c523996e0279c4c629a8fc31f4f109b2efd470c17f49c80"}, -] - -[package.dependencies] -beautifulsoup4 = "*" -pygments = ">=2.7,<3.0" -sphinx = ">=4.0,<5.0" - -[[package]] -name = "greenlet" -version = "2.0.2" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -files = [ - {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, - {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, - {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, - {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, - {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, - {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, - {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, - {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, - {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, - {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, - {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, - {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, - {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, - {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, - {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, - {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, - {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, - {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, - {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, - {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, - {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, - {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, - {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, - {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, - {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, - {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, - {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, - {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, - {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, - {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, - {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, - {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, - {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, - {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, - {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, - {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, - {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, - {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, -] - -[package.extras] -docs = ["Sphinx", "docutils (<0.18)"] -test = ["objgraph", "psutil"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "html5tagger" -version = "1.3.0" -description = "Pythonic HTML generation/templating (no template files)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "html5tagger-1.3.0-py3-none-any.whl", hash = "sha256:ce14313515edffec8ed8a36c5890d023922641171b4e6e5774ad1a74998f5351"}, - {file = "html5tagger-1.3.0.tar.gz", hash = "sha256:84fa3dfb49e5c83b79bbd856ab7b1de8e2311c3bb46a8be925f119e3880a8da9"}, -] - -[[package]] -name = "httptools" -version = "0.5.0" -description = "A collection of framework independent HTTP protocol utils." -optional = false -python-versions = ">=3.5.0" -files = [ - {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f470c79061599a126d74385623ff4744c4e0f4a0997a353a44923c0b561ee51"}, - {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e90491a4d77d0cb82e0e7a9cb35d86284c677402e4ce7ba6b448ccc7325c5421"}, - {file = "httptools-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1d2357f791b12d86faced7b5736dea9ef4f5ecdc6c3f253e445ee82da579449"}, - {file = "httptools-0.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f90cd6fd97c9a1b7fe9215e60c3bd97336742a0857f00a4cb31547bc22560c2"}, - {file = "httptools-0.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5230a99e724a1bdbbf236a1b58d6e8504b912b0552721c7c6b8570925ee0ccde"}, - {file = "httptools-0.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a47a34f6015dd52c9eb629c0f5a8a5193e47bf2a12d9a3194d231eaf1bc451a"}, - {file = "httptools-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:24bb4bb8ac3882f90aa95403a1cb48465de877e2d5298ad6ddcfdebec060787d"}, - {file = "httptools-0.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e67d4f8734f8054d2c4858570cc4b233bf753f56e85217de4dfb2495904cf02e"}, - {file = "httptools-0.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e5eefc58d20e4c2da82c78d91b2906f1a947ef42bd668db05f4ab4201a99f49"}, - {file = "httptools-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0297822cea9f90a38df29f48e40b42ac3d48a28637368f3ec6d15eebefd182f9"}, - {file = "httptools-0.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:557be7fbf2bfa4a2ec65192c254e151684545ebab45eca5d50477d562c40f986"}, - {file = "httptools-0.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:54465401dbbec9a6a42cf737627fb0f014d50dc7365a6b6cd57753f151a86ff0"}, - {file = "httptools-0.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4d9ebac23d2de960726ce45f49d70eb5466725c0087a078866043dad115f850f"}, - {file = "httptools-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8a34e4c0ab7b1ca17b8763613783e2458e77938092c18ac919420ab8655c8c1"}, - {file = "httptools-0.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f659d7a48401158c59933904040085c200b4be631cb5f23a7d561fbae593ec1f"}, - {file = "httptools-0.5.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1616b3ba965cd68e6f759eeb5d34fbf596a79e84215eeceebf34ba3f61fdc7"}, - {file = "httptools-0.5.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3625a55886257755cb15194efbf209584754e31d336e09e2ffe0685a76cb4b60"}, - {file = "httptools-0.5.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:72ad589ba5e4a87e1d404cc1cb1b5780bfcb16e2aec957b88ce15fe879cc08ca"}, - {file = "httptools-0.5.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:850fec36c48df5a790aa735417dca8ce7d4b48d59b3ebd6f83e88a8125cde324"}, - {file = "httptools-0.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f222e1e9d3f13b68ff8a835574eda02e67277d51631d69d7cf7f8e07df678c86"}, - {file = "httptools-0.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3cb8acf8f951363b617a8420768a9f249099b92e703c052f9a51b66342eea89b"}, - {file = "httptools-0.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550059885dc9c19a072ca6d6735739d879be3b5959ec218ba3e013fd2255a11b"}, - {file = "httptools-0.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a04fe458a4597aa559b79c7f48fe3dceabef0f69f562daf5c5e926b153817281"}, - {file = "httptools-0.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d0c1044bce274ec6711f0770fd2d5544fe392591d204c68328e60a46f88843b"}, - {file = "httptools-0.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c6eeefd4435055a8ebb6c5cc36111b8591c192c56a95b45fe2af22d9881eee25"}, - {file = "httptools-0.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5b65be160adcd9de7a7e6413a4966665756e263f0d5ddeffde277ffeee0576a5"}, - {file = "httptools-0.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fe9c766a0c35b7e3d6b6939393c8dfdd5da3ac5dec7f971ec9134f284c6c36d6"}, - {file = "httptools-0.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:85b392aba273566c3d5596a0a490978c085b79700814fb22bfd537d381dd230c"}, - {file = "httptools-0.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5e3088f4ed33947e16fd865b8200f9cfae1144f41b64a8cf19b599508e096bc"}, - {file = "httptools-0.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c2a56b6aad7cc8f5551d8e04ff5a319d203f9d870398b94702300de50190f63"}, - {file = "httptools-0.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b571b281a19762adb3f48a7731f6842f920fa71108aff9be49888320ac3e24d"}, - {file = "httptools-0.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa47ffcf70ba6f7848349b8a6f9b481ee0f7637931d91a9860a1838bfc586901"}, - {file = "httptools-0.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:bede7ee075e54b9a5bde695b4fc8f569f30185891796b2e4e09e2226801d09bd"}, - {file = "httptools-0.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:64eba6f168803a7469866a9c9b5263a7463fa8b7a25b35e547492aa7322036b6"}, - {file = "httptools-0.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b098e4bb1174096a93f48f6193e7d9aa7071506a5877da09a783509ca5fff42"}, - {file = "httptools-0.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9423a2de923820c7e82e18980b937893f4aa8251c43684fa1772e341f6e06887"}, - {file = "httptools-0.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca1b7becf7d9d3ccdbb2f038f665c0f4857e08e1d8481cbcc1a86a0afcfb62b2"}, - {file = "httptools-0.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:50d4613025f15f4b11f1c54bbed4761c0020f7f921b95143ad6d58c151198142"}, - {file = "httptools-0.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8ffce9d81c825ac1deaa13bc9694c0562e2840a48ba21cfc9f3b4c922c16f372"}, - {file = "httptools-0.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:1af91b3650ce518d226466f30bbba5b6376dbd3ddb1b2be8b0658c6799dd450b"}, - {file = "httptools-0.5.0.tar.gz", hash = "sha256:295874861c173f9101960bba332429bb77ed4dcd8cdf5cee9922eb00e4f6bc09"}, -] - -[package.extras] -test = ["Cython (>=0.29.24,<0.30.0)"] - -[[package]] -name = "idna" -version = "3.4" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] - -[[package]] -name = "imagesize" -version = "1.4.1" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, - {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, -] - -[[package]] -name = "importlib-metadata" -version = "6.6.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] - -[[package]] -name = "importlib-resources" -version = "5.12.0" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"}, - {file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] - -[[package]] -name = "itsdangerous" -version = "2.1.2" -description = "Safely pass data to untrusted environments and back." -optional = false -python-versions = ">=3.7" -files = [ - {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, - {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, -] - -[[package]] -name = "jinja2" -version = "3.1.2" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jsonpatch" -version = "1.32" -description = "Apply JSON-Patches (RFC 6902)" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "jsonpatch-1.32-py2.py3-none-any.whl", hash = "sha256:26ac385719ac9f54df8a2f0827bb8253aa3ea8ab7b3368457bcdb8c14595a397"}, - {file = "jsonpatch-1.32.tar.gz", hash = "sha256:b6ddfe6c3db30d81a96aaeceb6baf916094ffa23d7dd5fa2c13e13f8b6e600c2"}, -] - -[package.dependencies] -jsonpointer = ">=1.9" - -[[package]] -name = "jsonpointer" -version = "2.3" -description = "Identify specific nodes in a JSON document (RFC 6901)" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "jsonpointer-2.3-py2.py3-none-any.whl", hash = "sha256:51801e558539b4e9cd268638c078c6c5746c9ac96bc38152d443400e4f3793e9"}, - {file = "jsonpointer-2.3.tar.gz", hash = "sha256:97cba51526c829282218feb99dab1b1e6bdf8efd1c43dc9d57be093c0d69c99a"}, -] - -[[package]] -name = "kiwisolver" -version = "1.4.4" -description = "A fast implementation of the Cassowary constraint solver" -optional = false -python-versions = ">=3.7" -files = [ - {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"}, - {file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c"}, - {file = "kiwisolver-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3"}, - {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938"}, - {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d"}, - {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09"}, - {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de"}, - {file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32"}, - {file = "kiwisolver-1.4.4-cp310-cp310-win32.whl", hash = "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408"}, - {file = "kiwisolver-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004"}, - {file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6"}, - {file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2"}, - {file = "kiwisolver-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca"}, - {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69"}, - {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514"}, - {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494"}, - {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5"}, - {file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51"}, - {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da"}, - {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4"}, - {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626"}, - {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750"}, - {file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4"}, - {file = "kiwisolver-1.4.4-cp311-cp311-win32.whl", hash = "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e"}, - {file = "kiwisolver-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-win32.whl", hash = "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3"}, - {file = "kiwisolver-1.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166"}, - {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454"}, - {file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0"}, - {file = "kiwisolver-1.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c"}, - {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae"}, - {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0"}, - {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1"}, - {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d"}, - {file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c"}, - {file = "kiwisolver-1.4.4-cp38-cp38-win32.whl", hash = "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191"}, - {file = "kiwisolver-1.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766"}, - {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8"}, - {file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897"}, - {file = "kiwisolver-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824"}, - {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29"}, - {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f"}, - {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd"}, - {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac"}, - {file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9"}, - {file = "kiwisolver-1.4.4-cp39-cp39-win32.whl", hash = "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea"}, - {file = "kiwisolver-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b"}, - {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a"}, - {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d"}, - {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a"}, - {file = "kiwisolver-1.4.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871"}, - {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9"}, - {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8"}, - {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286"}, - {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb"}, - {file = "kiwisolver-1.4.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f"}, - {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008"}, - {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767"}, - {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9"}, - {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2"}, - {file = "kiwisolver-1.4.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b"}, - {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"}, -] - -[[package]] -name = "livereload" -version = "2.6.3" -description = "Python LiveReload is an awesome tool for web developers" -optional = false -python-versions = "*" -files = [ - {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"}, - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, -] - -[package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} - -[[package]] -name = "lxml" -version = "4.9.2" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -files = [ - {file = "lxml-4.9.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2"}, - {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892"}, - {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a"}, - {file = "lxml-4.9.2-cp27-cp27m-win32.whl", hash = "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de"}, - {file = "lxml-4.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3"}, - {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50"}, - {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975"}, - {file = "lxml-4.9.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c"}, - {file = "lxml-4.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a"}, - {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4"}, - {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4"}, - {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7"}, - {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184"}, - {file = "lxml-4.9.2-cp310-cp310-win32.whl", hash = "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda"}, - {file = "lxml-4.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab"}, - {file = "lxml-4.9.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9"}, - {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf"}, - {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380"}, - {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92"}, - {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1"}, - {file = "lxml-4.9.2-cp311-cp311-win32.whl", hash = "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33"}, - {file = "lxml-4.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd"}, - {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0"}, - {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e"}, - {file = "lxml-4.9.2-cp35-cp35m-win32.whl", hash = "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df"}, - {file = "lxml-4.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5"}, - {file = "lxml-4.9.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e"}, - {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74"}, - {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38"}, - {file = "lxml-4.9.2-cp36-cp36m-win32.whl", hash = "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5"}, - {file = "lxml-4.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3"}, - {file = "lxml-4.9.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45"}, - {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e"}, - {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b"}, - {file = "lxml-4.9.2-cp37-cp37m-win32.whl", hash = "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe"}, - {file = "lxml-4.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9"}, - {file = "lxml-4.9.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c"}, - {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f"}, - {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457"}, - {file = "lxml-4.9.2-cp38-cp38-win32.whl", hash = "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b"}, - {file = "lxml-4.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7"}, - {file = "lxml-4.9.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5"}, - {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5"}, - {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2"}, - {file = "lxml-4.9.2-cp39-cp39-win32.whl", hash = "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1"}, - {file = "lxml-4.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f"}, - {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c"}, - {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409"}, - {file = "lxml-4.9.2.tar.gz", hash = "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=0.29.7)"] - -[[package]] -name = "markupsafe" -version = "2.0.1" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.6" -files = [ - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, - {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, -] - -[[package]] -name = "matplotlib" -version = "3.7.1" -description = "Python plotting package" -optional = false -python-versions = ">=3.8" -files = [ - {file = "matplotlib-3.7.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:95cbc13c1fc6844ab8812a525bbc237fa1470863ff3dace7352e910519e194b1"}, - {file = "matplotlib-3.7.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:08308bae9e91aca1ec6fd6dda66237eef9f6294ddb17f0d0b3c863169bf82353"}, - {file = "matplotlib-3.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:544764ba51900da4639c0f983b323d288f94f65f4024dc40ecb1542d74dc0500"}, - {file = "matplotlib-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56d94989191de3fcc4e002f93f7f1be5da476385dde410ddafbb70686acf00ea"}, - {file = "matplotlib-3.7.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99bc9e65901bb9a7ce5e7bb24af03675cbd7c70b30ac670aa263240635999a4"}, - {file = "matplotlib-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb7d248c34a341cd4c31a06fd34d64306624c8cd8d0def7abb08792a5abfd556"}, - {file = "matplotlib-3.7.1-cp310-cp310-win32.whl", hash = "sha256:ce463ce590f3825b52e9fe5c19a3c6a69fd7675a39d589e8b5fbe772272b3a24"}, - {file = "matplotlib-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3d7bc90727351fb841e4d8ae620d2d86d8ed92b50473cd2b42ce9186104ecbba"}, - {file = "matplotlib-3.7.1-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:770a205966d641627fd5cf9d3cb4b6280a716522cd36b8b284a8eb1581310f61"}, - {file = "matplotlib-3.7.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f67bfdb83a8232cb7a92b869f9355d677bce24485c460b19d01970b64b2ed476"}, - {file = "matplotlib-3.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2bf092f9210e105f414a043b92af583c98f50050559616930d884387d0772aba"}, - {file = "matplotlib-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89768d84187f31717349c6bfadc0e0d8c321e8eb34522acec8a67b1236a66332"}, - {file = "matplotlib-3.7.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83111e6388dec67822e2534e13b243cc644c7494a4bb60584edbff91585a83c6"}, - {file = "matplotlib-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a867bf73a7eb808ef2afbca03bcdb785dae09595fbe550e1bab0cd023eba3de0"}, - {file = "matplotlib-3.7.1-cp311-cp311-win32.whl", hash = "sha256:fbdeeb58c0cf0595efe89c05c224e0a502d1aa6a8696e68a73c3efc6bc354304"}, - {file = "matplotlib-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0bd19c72ae53e6ab979f0ac6a3fafceb02d2ecafa023c5cca47acd934d10be7"}, - {file = "matplotlib-3.7.1-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:6eb88d87cb2c49af00d3bbc33a003f89fd9f78d318848da029383bfc08ecfbfb"}, - {file = "matplotlib-3.7.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:cf0e4f727534b7b1457898c4f4ae838af1ef87c359b76dcd5330fa31893a3ac7"}, - {file = "matplotlib-3.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:46a561d23b91f30bccfd25429c3c706afe7d73a5cc64ef2dfaf2b2ac47c1a5dc"}, - {file = "matplotlib-3.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8704726d33e9aa8a6d5215044b8d00804561971163563e6e6591f9dcf64340cc"}, - {file = "matplotlib-3.7.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4cf327e98ecf08fcbb82685acaf1939d3338548620ab8dfa02828706402c34de"}, - {file = "matplotlib-3.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:617f14ae9d53292ece33f45cba8503494ee199a75b44de7717964f70637a36aa"}, - {file = "matplotlib-3.7.1-cp38-cp38-win32.whl", hash = "sha256:7c9a4b2da6fac77bcc41b1ea95fadb314e92508bf5493ceff058e727e7ecf5b0"}, - {file = "matplotlib-3.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:14645aad967684e92fc349493fa10c08a6da514b3d03a5931a1bac26e6792bd1"}, - {file = "matplotlib-3.7.1-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:81a6b377ea444336538638d31fdb39af6be1a043ca5e343fe18d0f17e098770b"}, - {file = "matplotlib-3.7.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:28506a03bd7f3fe59cd3cd4ceb2a8d8a2b1db41afede01f66c42561b9be7b4b7"}, - {file = "matplotlib-3.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8c587963b85ce41e0a8af53b9b2de8dddbf5ece4c34553f7bd9d066148dc719c"}, - {file = "matplotlib-3.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bf26ade3ff0f27668989d98c8435ce9327d24cffb7f07d24ef609e33d582439"}, - {file = "matplotlib-3.7.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:def58098f96a05f90af7e92fd127d21a287068202aa43b2a93476170ebd99e87"}, - {file = "matplotlib-3.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f883a22a56a84dba3b588696a2b8a1ab0d2c3d41be53264115c71b0a942d8fdb"}, - {file = "matplotlib-3.7.1-cp39-cp39-win32.whl", hash = "sha256:4f99e1b234c30c1e9714610eb0c6d2f11809c9c78c984a613ae539ea2ad2eb4b"}, - {file = "matplotlib-3.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:3ba2af245e36990facf67fde840a760128ddd71210b2ab6406e640188d69d136"}, - {file = "matplotlib-3.7.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3032884084f541163f295db8a6536e0abb0db464008fadca6c98aaf84ccf4717"}, - {file = "matplotlib-3.7.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a2cb34336110e0ed8bb4f650e817eed61fa064acbefeb3591f1b33e3a84fd96"}, - {file = "matplotlib-3.7.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b867e2f952ed592237a1828f027d332d8ee219ad722345b79a001f49df0936eb"}, - {file = "matplotlib-3.7.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:57bfb8c8ea253be947ccb2bc2d1bb3862c2bccc662ad1b4626e1f5e004557042"}, - {file = "matplotlib-3.7.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:438196cdf5dc8d39b50a45cb6e3f6274edbcf2254f85fa9b895bf85851c3a613"}, - {file = "matplotlib-3.7.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:21e9cff1a58d42e74d01153360de92b326708fb205250150018a52c70f43c290"}, - {file = "matplotlib-3.7.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75d4725d70b7c03e082bbb8a34639ede17f333d7247f56caceb3801cb6ff703d"}, - {file = "matplotlib-3.7.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:97cc368a7268141afb5690760921765ed34867ffb9655dd325ed207af85c7529"}, - {file = "matplotlib-3.7.1.tar.gz", hash = "sha256:7b73305f25eab4541bd7ee0b96d87e53ae9c9f1823be5659b806cd85786fe882"}, -] - -[package.dependencies] -contourpy = ">=1.0.1" -cycler = ">=0.10" -fonttools = ">=4.22.0" -importlib-resources = {version = ">=3.2.0", markers = "python_version < \"3.10\""} -kiwisolver = ">=1.0.1" -numpy = ">=1.20" -packaging = ">=20.0" -pillow = ">=6.2.0" -pyparsing = ">=2.3.1" -python-dateutil = ">=2.7" - -[[package]] -name = "multidict" -version = "6.0.4" -description = "multidict implementation" -optional = false -python-versions = ">=3.7" -files = [ - {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, - {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, - {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"}, - {file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"}, - {file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"}, - {file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"}, - {file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"}, - {file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"}, - {file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"}, - {file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"}, - {file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"}, - {file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"}, - {file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"}, - {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, - {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, -] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "numpy" -version = "1.24.3" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "numpy-1.24.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c1104d3c036fb81ab923f507536daedc718d0ad5a8707c6061cdfd6d184e570"}, - {file = "numpy-1.24.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:202de8f38fc4a45a3eea4b63e2f376e5f2dc64ef0fa692838e31a808520efaf7"}, - {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8535303847b89aa6b0f00aa1dc62867b5a32923e4d1681a35b5eef2d9591a463"}, - {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d926b52ba1367f9acb76b0df6ed21f0b16a1ad87c6720a1121674e5cf63e2b6"}, - {file = "numpy-1.24.3-cp310-cp310-win32.whl", hash = "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b"}, - {file = "numpy-1.24.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f23af8c16022663a652d3b25dcdc272ac3f83c3af4c02eb8b824e6b3ab9d7"}, - {file = "numpy-1.24.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a7721ec204d3a237225db3e194c25268faf92e19338a35f3a224469cb6039a3"}, - {file = "numpy-1.24.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d6cc757de514c00b24ae8cf5c876af2a7c3df189028d68c0cb4eaa9cd5afc2bf"}, - {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e3f4e85fc5d4fd311f6e9b794d0c00e7002ec122be271f2019d63376f1d385"}, - {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1d3c026f57ceaad42f8231305d4653d5f05dc6332a730ae5c0bea3513de0950"}, - {file = "numpy-1.24.3-cp311-cp311-win32.whl", hash = "sha256:c91c4afd8abc3908e00a44b2672718905b8611503f7ff87390cc0ac3423fb096"}, - {file = "numpy-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:5342cf6aad47943286afa6f1609cad9b4266a05e7f2ec408e2cf7aea7ff69d80"}, - {file = "numpy-1.24.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7776ea65423ca6a15255ba1872d82d207bd1e09f6d0894ee4a64678dd2204078"}, - {file = "numpy-1.24.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ae8d0be48d1b6ed82588934aaaa179875e7dc4f3d84da18d7eae6eb3f06c242c"}, - {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecde0f8adef7dfdec993fd54b0f78183051b6580f606111a6d789cd14c61ea0c"}, - {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4749e053a29364d3452c034827102ee100986903263e89884922ef01a0a6fd2f"}, - {file = "numpy-1.24.3-cp38-cp38-win32.whl", hash = "sha256:d933fabd8f6a319e8530d0de4fcc2e6a61917e0b0c271fded460032db42a0fe4"}, - {file = "numpy-1.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:56e48aec79ae238f6e4395886b5eaed058abb7231fb3361ddd7bfdf4eed54289"}, - {file = "numpy-1.24.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4719d5aefb5189f50887773699eaf94e7d1e02bf36c1a9d353d9f46703758ca4"}, - {file = "numpy-1.24.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ec87a7084caa559c36e0a2309e4ecb1baa03b687201d0a847c8b0ed476a7187"}, - {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea8282b9bcfe2b5e7d491d0bf7f3e2da29700cec05b49e64d6246923329f2b02"}, - {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210461d87fb02a84ef243cac5e814aad2b7f4be953b32cb53327bb49fd77fbb4"}, - {file = "numpy-1.24.3-cp39-cp39-win32.whl", hash = "sha256:784c6da1a07818491b0ffd63c6bbe5a33deaa0e25a20e1b3ea20cf0e43f8046c"}, - {file = "numpy-1.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:d5036197ecae68d7f491fcdb4df90082b0d4960ca6599ba2659957aafced7c17"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:352ee00c7f8387b44d19f4cada524586f07379c0d49270f87233983bc5087ca0"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d6acc2e7524c9955e5c903160aa4ea083736fde7e91276b0e5d98e6332812"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:35400e6a8d102fd07c71ed7dcadd9eb62ee9a6e84ec159bd48c28235bbb0f8e4"}, - {file = "numpy-1.24.3.tar.gz", hash = "sha256:ab344f1bf21f140adab8e47fdbc7c35a477dc01408791f8ba00d018dd0bc5155"}, -] - -[[package]] -name = "packaging" -version = "23.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, -] - -[[package]] -name = "pillow" -version = "9.5.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Pillow-9.5.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16"}, - {file = "Pillow-9.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a"}, - {file = "Pillow-9.5.0-cp310-cp310-win32.whl", hash = "sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44"}, - {file = "Pillow-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296"}, - {file = "Pillow-9.5.0-cp311-cp311-win32.whl", hash = "sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec"}, - {file = "Pillow-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4"}, - {file = "Pillow-9.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089"}, - {file = "Pillow-9.5.0-cp312-cp312-win32.whl", hash = "sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb"}, - {file = "Pillow-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b"}, - {file = "Pillow-9.5.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5d4ebf8e1db4441a55c509c4baa7a0587a0210f7cd25fcfe74dbbce7a4bd1906"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375f6e5ee9620a271acb6820b3d1e94ffa8e741c0601db4c0c4d3cb0a9c224bf"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99eb6cafb6ba90e436684e08dad8be1637efb71c4f2180ee6b8f940739406e78"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfaaf10b6172697b9bceb9a3bd7b951819d1ca339a5ef294d1f1ac6d7f63270"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:763782b2e03e45e2c77d7779875f4432e25121ef002a41829d8868700d119392"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:35f6e77122a0c0762268216315bf239cf52b88865bba522999dc38f1c52b9b47"}, - {file = "Pillow-9.5.0-cp37-cp37m-win32.whl", hash = "sha256:aca1c196f407ec7cf04dcbb15d19a43c507a81f7ffc45b690899d6a76ac9fda7"}, - {file = "Pillow-9.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322724c0032af6692456cd6ed554bb85f8149214d97398bb80613b04e33769f6"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:a0aa9417994d91301056f3d0038af1199eb7adc86e646a36b9e050b06f526597"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8286396b351785801a976b1e85ea88e937712ee2c3ac653710a4a57a8da5d9c"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c830a02caeb789633863b466b9de10c015bded434deb3ec87c768e53752ad22a"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbd359831c1657d69bb81f0db962905ee05e5e9451913b18b831febfe0519082"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8fc330c3370a81bbf3f88557097d1ea26cd8b019d6433aa59f71195f5ddebbf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:7002d0797a3e4193c7cdee3198d7c14f92c0836d6b4a3f3046a64bd1ce8df2bf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:229e2c79c00e85989a34b5981a2b67aa079fd08c903f0aaead522a1d68d79e51"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9adf58f5d64e474bed00d69bcd86ec4bcaa4123bfa70a65ce72e424bfb88ed96"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:662da1f3f89a302cc22faa9f14a262c2e3951f9dbc9617609a47521c69dd9f8f"}, - {file = "Pillow-9.5.0-cp38-cp38-win32.whl", hash = "sha256:6608ff3bf781eee0cd14d0901a2b9cc3d3834516532e3bd673a0a204dc8615fc"}, - {file = "Pillow-9.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:e49eb4e95ff6fd7c0c402508894b1ef0e01b99a44320ba7d8ecbabefddcc5569"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:482877592e927fd263028c105b36272398e3e1be3269efda09f6ba21fd83ec66"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ded42b9ad70e5f1754fb7c2e2d6465a9c842e41d178f262e08b8c85ed8a1d8e"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c446d2245ba29820d405315083d55299a796695d747efceb5717a8b450324115"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aca1152d93dcc27dc55395604dcfc55bed5f25ef4c98716a928bacba90d33a3"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:608488bdcbdb4ba7837461442b90ea6f3079397ddc968c31265c1e056964f1ef"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:60037a8db8750e474af7ffc9faa9b5859e6c6d0a50e55c45576bf28be7419705"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a127ae76092974abfbfa38ca2d12cbeddcdeac0fb71f9627cc1135bedaf9d51a"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:489f8389261e5ed43ac8ff7b453162af39c3e8abd730af8363587ba64bb2e865"}, - {file = "Pillow-9.5.0-cp39-cp39-win32.whl", hash = "sha256:9b1af95c3a967bf1da94f253e56b6286b50af23392a886720f563c547e48e964"}, - {file = "Pillow-9.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:77165c4a5e7d5a284f10a6efaa39a0ae8ba839da344f20b111d62cc932fa4e5d"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:833b86a98e0ede388fa29363159c9b1a294b0905b5128baf01db683672f230f5"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaf305d6d40bd9632198c766fb64f0c1a83ca5b667f16c1e79e1661ab5060140"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:91ec6fe47b5eb5a9968c79ad9ed78c342b1f97a091677ba0e012701add857829"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cb841572862f629b99725ebaec3287fc6d275be9b14443ea746c1dd325053cbd"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c380b27d041209b849ed246b111b7c166ba36d7933ec6e41175fd15ab9eb1572"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c9af5a3b406a50e313467e3565fc99929717f780164fe6fbb7704edba0cebbe"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5671583eab84af046a397d6d0ba25343c00cd50bce03787948e0fff01d4fd9b1"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:84a6f19ce086c1bf894644b43cd129702f781ba5751ca8572f08aa40ef0ab7b7"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1e7723bd90ef94eda669a3c2c19d549874dd5badaeefabefd26053304abe5799"}, - {file = "Pillow-9.5.0.tar.gz", hash = "sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "playwright" -version = "1.34.0" -description = "A high-level API to automate web browsers" -optional = false -python-versions = ">=3.7" -files = [ - {file = "playwright-1.34.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:69bb9b3296e366a23a99277b4c7673cb54ce71a3f5d630f114f7701b61f98f25"}, - {file = "playwright-1.34.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:402d946631c8458436e099d7731bbf54cf79c9e62e3acae0ea8421e72616926b"}, - {file = "playwright-1.34.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:462251cda0fcbb273497d357dbe14b11e43ebceb0bac9b892beda041ff209aa9"}, - {file = "playwright-1.34.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:a8ba124ea302596a03a66993cd500484fb255cbc10fe0757fa4d49f974267a80"}, - {file = "playwright-1.34.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf0cb6aac49d24335fe361868aea72b11f276a95e7809f1a5d1c69b4120c46ac"}, - {file = "playwright-1.34.0-py3-none-win32.whl", hash = "sha256:c50fef189d87243cc09ae0feb8e417fbe434359ccbcc863fb19ba06d46d31c33"}, - {file = "playwright-1.34.0-py3-none-win_amd64.whl", hash = "sha256:42e16c930e1e910461f4c551a72fc1b900f37124431bf2b6a6d9ddae70042db4"}, -] - -[package.dependencies] -greenlet = "2.0.2" -pyee = "9.0.4" - -[[package]] -name = "pydantic" -version = "1.10.8" -description = "Data validation and settings management using python type hints" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pydantic-1.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1243d28e9b05003a89d72e7915fdb26ffd1d39bdd39b00b7dbe4afae4b557f9d"}, - {file = "pydantic-1.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0ab53b609c11dfc0c060d94335993cc2b95b2150e25583bec37a49b2d6c6c3f"}, - {file = "pydantic-1.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9613fadad06b4f3bc5db2653ce2f22e0de84a7c6c293909b48f6ed37b83c61f"}, - {file = "pydantic-1.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df7800cb1984d8f6e249351139667a8c50a379009271ee6236138a22a0c0f319"}, - {file = "pydantic-1.10.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0c6fafa0965b539d7aab0a673a046466d23b86e4b0e8019d25fd53f4df62c277"}, - {file = "pydantic-1.10.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e82d4566fcd527eae8b244fa952d99f2ca3172b7e97add0b43e2d97ee77f81ab"}, - {file = "pydantic-1.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:ab523c31e22943713d80d8d342d23b6f6ac4b792a1e54064a8d0cf78fd64e800"}, - {file = "pydantic-1.10.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:666bdf6066bf6dbc107b30d034615d2627e2121506c555f73f90b54a463d1f33"}, - {file = "pydantic-1.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:35db5301b82e8661fa9c505c800d0990bc14e9f36f98932bb1d248c0ac5cada5"}, - {file = "pydantic-1.10.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90c1e29f447557e9e26afb1c4dbf8768a10cc676e3781b6a577841ade126b85"}, - {file = "pydantic-1.10.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93e766b4a8226e0708ef243e843105bf124e21331694367f95f4e3b4a92bbb3f"}, - {file = "pydantic-1.10.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88f195f582851e8db960b4a94c3e3ad25692c1c1539e2552f3df7a9e972ef60e"}, - {file = "pydantic-1.10.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:34d327c81e68a1ecb52fe9c8d50c8a9b3e90d3c8ad991bfc8f953fb477d42fb4"}, - {file = "pydantic-1.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:d532bf00f381bd6bc62cabc7d1372096b75a33bc197a312b03f5838b4fb84edd"}, - {file = "pydantic-1.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7d5b8641c24886d764a74ec541d2fc2c7fb19f6da2a4001e6d580ba4a38f7878"}, - {file = "pydantic-1.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b1f6cb446470b7ddf86c2e57cd119a24959af2b01e552f60705910663af09a4"}, - {file = "pydantic-1.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33b60054b2136aef8cf190cd4c52a3daa20b2263917c49adad20eaf381e823b"}, - {file = "pydantic-1.10.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1952526ba40b220b912cdc43c1c32bcf4a58e3f192fa313ee665916b26befb68"}, - {file = "pydantic-1.10.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bb14388ec45a7a0dc429e87def6396f9e73c8c77818c927b6a60706603d5f2ea"}, - {file = "pydantic-1.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:16f8c3e33af1e9bb16c7a91fc7d5fa9fe27298e9f299cff6cb744d89d573d62c"}, - {file = "pydantic-1.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ced8375969673929809d7f36ad322934c35de4af3b5e5b09ec967c21f9f7887"}, - {file = "pydantic-1.10.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93e6bcfccbd831894a6a434b0aeb1947f9e70b7468f274154d03d71fabb1d7c6"}, - {file = "pydantic-1.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:191ba419b605f897ede9892f6c56fb182f40a15d309ef0142212200a10af4c18"}, - {file = "pydantic-1.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:052d8654cb65174d6f9490cc9b9a200083a82cf5c3c5d3985db765757eb3b375"}, - {file = "pydantic-1.10.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ceb6a23bf1ba4b837d0cfe378329ad3f351b5897c8d4914ce95b85fba96da5a1"}, - {file = "pydantic-1.10.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f2e754d5566f050954727c77f094e01793bcb5725b663bf628fa6743a5a9108"}, - {file = "pydantic-1.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:6a82d6cda82258efca32b40040228ecf43a548671cb174a1e81477195ed3ed56"}, - {file = "pydantic-1.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e59417ba8a17265e632af99cc5f35ec309de5980c440c255ab1ca3ae96a3e0e"}, - {file = "pydantic-1.10.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84d80219c3f8d4cad44575e18404099c76851bc924ce5ab1c4c8bb5e2a2227d0"}, - {file = "pydantic-1.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e4148e635994d57d834be1182a44bdb07dd867fa3c2d1b37002000646cc5459"}, - {file = "pydantic-1.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12f7b0bf8553e310e530e9f3a2f5734c68699f42218bf3568ef49cd9b0e44df4"}, - {file = "pydantic-1.10.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42aa0c4b5c3025483240a25b09f3c09a189481ddda2ea3a831a9d25f444e03c1"}, - {file = "pydantic-1.10.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:17aef11cc1b997f9d574b91909fed40761e13fac438d72b81f902226a69dac01"}, - {file = "pydantic-1.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:66a703d1983c675a6e0fed8953b0971c44dba48a929a2000a493c3772eb61a5a"}, - {file = "pydantic-1.10.8-py3-none-any.whl", hash = "sha256:7456eb22ed9aaa24ff3e7b4757da20d9e5ce2a81018c1b3ebd81a0b88a18f3b2"}, - {file = "pydantic-1.10.8.tar.gz", hash = "sha256:1410275520dfa70effadf4c21811d755e7ef9bb1f1d077a21958153a92c8d9ca"}, -] - -[package.dependencies] -typing-extensions = ">=4.2.0" - -[package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] - -[[package]] -name = "pyee" -version = "9.0.4" -description = "A port of node.js's EventEmitter to python." -optional = false -python-versions = "*" -files = [ - {file = "pyee-9.0.4-py2.py3-none-any.whl", hash = "sha256:9f066570130c554e9cc12de5a9d86f57c7ee47fece163bbdaa3e9c933cfbdfa5"}, - {file = "pyee-9.0.4.tar.gz", hash = "sha256:2770c4928abc721f46b705e6a72b0c59480c4a69c9a83ca0b00bb994f1ea4b32"}, -] - -[package.dependencies] -typing-extensions = "*" - -[[package]] -name = "pygments" -version = "2.15.1" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, - {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, -] - -[package.extras] -plugins = ["importlib-metadata"] - -[[package]] -name = "pyparsing" -version = "3.0.9" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.6.8" -files = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-dotenv" -version = "1.0.0" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, - {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "pyyaml" -version = "6.0" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, -] - -[[package]] -name = "reactpy" -version = "1.0.0" -description = "Reactive user interfaces with pure Python" -optional = false -python-versions = ">=3.9" -files = [] -develop = false - -[package.dependencies] -anyio = ">=3" -asgiref = ">=3" -colorlog = ">=6" -fastapi = {version = ">=0.63.0", optional = true, markers = "extra == \"fastapi\""} -fastjsonschema = ">=2.14.5" -flask = {version = "*", optional = true, markers = "extra == \"flask\""} -flask-cors = {version = "*", optional = true, markers = "extra == \"flask\""} -flask-sock = {version = "*", optional = true, markers = "extra == \"flask\""} -jsonpatch = ">=1.32" -lxml = ">=4" -markupsafe = {version = ">=1.1.1,<2.1", optional = true, markers = "extra == \"flask\""} -mypy-extensions = ">=0.4.3" -playwright = {version = "*", optional = true, markers = "extra == \"testing\""} -requests = ">=2" -sanic = {version = ">=21", optional = true, markers = "extra == \"sanic\""} -sanic-cors = {version = "*", optional = true, markers = "extra == \"sanic\""} -starlette = {version = ">=0.13.6", optional = true, markers = "extra == \"starlette\""} -tornado = {version = "*", optional = true, markers = "extra == \"tornado\""} -typing-extensions = ">=3.10" -uvicorn = {version = ">=0.19.0", extras = ["standard"], optional = true, markers = "extra == \"fastapi\" or extra == \"sanic\" or extra == \"starlette\""} - -[package.extras] -all = ["reactpy[fastapi,flask,sanic,starlette,testing,tornado]"] -fastapi = ["fastapi (>=0.63.0)", "uvicorn[standard] (>=0.19.0)"] -flask = ["flask", "flask-cors", "flask-sock", "markupsafe (>=1.1.1,<2.1)"] -sanic = ["sanic (>=21)", "sanic-cors", "uvicorn[standard] (>=0.19.0)"] -starlette = ["starlette (>=0.13.6)", "uvicorn[standard] (>=0.19.0)"] -testing = ["playwright"] -tornado = ["tornado"] - -[package.source] -type = "directory" -url = "../src/py/reactpy" - -[[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.7" -files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "sanic" -version = "23.3.0" -description = "A web server and web framework that's written to go fast. Build fast. Run fast." -optional = false -python-versions = ">=3.7" -files = [ - {file = "sanic-23.3.0-py3-none-any.whl", hash = "sha256:7cafbd63da9c6c6d8aeb8cb4304addf8a274352ab812014386c63e55f474fbee"}, - {file = "sanic-23.3.0.tar.gz", hash = "sha256:b80ebc5c38c983cb45ae5ecc7a669a54c823ec1dff297fbd5f817b1e9e9e49af"}, -] - -[package.dependencies] -aiofiles = ">=0.6.0" -html5tagger = ">=1.2.1" -httptools = ">=0.0.10" -multidict = ">=5.0,<7.0" -sanic-routing = ">=22.8.0" -tracerite = ">=1.0.0" -ujson = {version = ">=1.35", markers = "sys_platform != \"win32\" and implementation_name == \"cpython\""} -uvloop = {version = ">=0.15.0", markers = "sys_platform != \"win32\" and implementation_name == \"cpython\""} -websockets = ">=10.0" - -[package.extras] -all = ["bandit", "beautifulsoup4", "black", "chardet (==3.*)", "coverage", "cryptography", "docutils", "enum-tools[sphinx]", "flake8", "isort (>=5.0.0)", "m2r2", "mistune (<2.0.0)", "mypy (>=0.901,<0.910)", "pygments", "pytest (==7.1.*)", "pytest-benchmark", "pytest-sanic", "sanic-testing (>=23.3.0)", "slotscheck (>=0.8.0,<1)", "sphinx (>=2.1.2)", "sphinx-rtd-theme (>=0.4.3)", "towncrier", "tox", "types-ujson", "uvicorn (<0.15.0)"] -dev = ["bandit", "beautifulsoup4", "black", "chardet (==3.*)", "coverage", "cryptography", "docutils", "flake8", "isort (>=5.0.0)", "mypy (>=0.901,<0.910)", "pygments", "pytest (==7.1.*)", "pytest-benchmark", "pytest-sanic", "sanic-testing (>=23.3.0)", "slotscheck (>=0.8.0,<1)", "towncrier", "tox", "types-ujson", "uvicorn (<0.15.0)"] -docs = ["docutils", "enum-tools[sphinx]", "m2r2", "mistune (<2.0.0)", "pygments", "sphinx (>=2.1.2)", "sphinx-rtd-theme (>=0.4.3)"] -ext = ["sanic-ext"] -http3 = ["aioquic"] -test = ["bandit", "beautifulsoup4", "black", "chardet (==3.*)", "coverage", "docutils", "flake8", "isort (>=5.0.0)", "mypy (>=0.901,<0.910)", "pygments", "pytest (==7.1.*)", "pytest-benchmark", "pytest-sanic", "sanic-testing (>=23.3.0)", "slotscheck (>=0.8.0,<1)", "types-ujson", "uvicorn (<0.15.0)"] - -[[package]] -name = "sanic-cors" -version = "2.2.0" -description = "A Sanic extension adding a decorator for CORS support. Based on flask-cors by Cory Dolphin." -optional = false -python-versions = "*" -files = [ - {file = "Sanic-Cors-2.2.0.tar.gz", hash = "sha256:f8d7515da4c8b837871d422c66314c4b5704396a78894b59c50e26aa72a95873"}, - {file = "Sanic_Cors-2.2.0-py2.py3-none-any.whl", hash = "sha256:c3b133ff1f0bb609a53db35f727f5c371dc4ebeb6be4cc2c37c19dd8b9301115"}, -] - -[package.dependencies] -packaging = ">=21.3" -sanic = ">=21.9.3" - -[[package]] -name = "sanic-routing" -version = "22.8.0" -description = "Core routing component for Sanic" -optional = false -python-versions = "*" -files = [ - {file = "sanic-routing-22.8.0.tar.gz", hash = "sha256:305729b4e0bf01f074044a2a315ff401fa7eeffb009eec1d2c81d35e1038ddfc"}, - {file = "sanic_routing-22.8.0-py3-none-any.whl", hash = "sha256:9a928ed9e19a36bc019223be90a5da0ab88cdd76b101e032510b6a7073c017e9"}, -] - -[[package]] -name = "simple-websocket" -version = "0.10.0" -description = "Simple WebSocket server and client for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "simple-websocket-0.10.0.tar.gz", hash = "sha256:82c0b0b1006d5490f09ff66392394d90dd758285635edad241e093e9a8abd3eb"}, - {file = "simple_websocket-0.10.0-py3-none-any.whl", hash = "sha256:fc1bc56c393a187e7268f8ab99da1a8e8da9b5dfb7769a2f3b8dada00067745b"}, -] - -[package.dependencies] -wsproto = "*" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] - -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = false -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - -[[package]] -name = "soupsieve" -version = "2.4.1" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "soupsieve-2.4.1-py3-none-any.whl", hash = "sha256:1c1bfee6819544a3447586c889157365a27e10d88cde3ad3da0cf0ddf646feb8"}, - {file = "soupsieve-2.4.1.tar.gz", hash = "sha256:89d12b2d5dfcd2c9e8c22326da9d9aa9cb3dfab0a83a024f05704076ee8d35ea"}, -] - -[[package]] -name = "sphinx" -version = "4.5.0" -description = "Python documentation generator" -optional = false -python-versions = ">=3.6" -files = [ - {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, - {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, -] - -[package.dependencies] -alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.14,<0.18" -imagesize = "*" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.3" -packaging = "*" -Pygments = ">=2.0" -requests = ">=2.5.0" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.5" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] - -[[package]] -name = "sphinx-autobuild" -version = "2021.3.14" -description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." -optional = false -python-versions = ">=3.6" -files = [ - {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, - {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"}, -] - -[package.dependencies] -colorama = "*" -livereload = "*" -sphinx = "*" - -[package.extras] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "sphinx-autodoc-typehints" -version = "1.19.1" -description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinx_autodoc_typehints-1.19.1-py3-none-any.whl", hash = "sha256:9be46aeeb1b315eb5df1f3a7cb262149895d16c7d7dcd77b92513c3c3a1e85e6"}, - {file = "sphinx_autodoc_typehints-1.19.1.tar.gz", hash = "sha256:6c841db55e0e9be0483ff3962a2152b60e79306f4288d8c4e7e86ac84486a5ea"}, -] - -[package.dependencies] -Sphinx = ">=4.5" - -[package.extras] -testing = ["covdefaults (>=2.2)", "coverage (>=6.3)", "diff-cover (>=6.4)", "nptyping (>=2.1.2)", "pytest (>=7.1)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=4.1)"] -type-comments = ["typed-ast (>=1.5.2)"] - -[[package]] -name = "sphinx-copybutton" -version = "0.5.2" -description = "Add a copy button to each of your code cells." -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"}, - {file = "sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e"}, -] - -[package.dependencies] -sphinx = ">=1.8" - -[package.extras] -code-style = ["pre-commit (==2.12.1)"] -rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"] - -[[package]] -name = "sphinx-design" -version = "0.4.1" -description = "A sphinx extension for designing beautiful, view size responsive web components." -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinx_design-0.4.1-py3-none-any.whl", hash = "sha256:23bf5705eb31296d4451f68b0222a698a8a84396ffe8378dfd9319ba7ab8efd9"}, - {file = "sphinx_design-0.4.1.tar.gz", hash = "sha256:5b6418ba4a2dc3d83592ea0ff61a52a891fe72195a4c3a18b2fa1c7668ce4708"}, -] - -[package.dependencies] -sphinx = ">=4,<7" - -[package.extras] -code-style = ["pre-commit (>=2.12,<3.0)"] -rtd = ["myst-parser (>=0.18.0,<2)"] -testing = ["myst-parser (>=0.18.0,<2)", "pytest (>=7.1,<8.0)", "pytest-cov", "pytest-regressions"] -theme-furo = ["furo (>=2022.06.04,<2022.07)"] -theme-pydata = ["pydata-sphinx-theme (>=0.9.0,<0.10.0)"] -theme-rtd = ["sphinx-rtd-theme (>=1.0,<2.0)"] -theme-sbt = ["sphinx-book-theme (>=0.3.0,<0.4.0)"] - -[[package]] -name = "sphinx-reredirects" -version = "0.1.2" -description = "Handles redirects for moved pages in Sphinx documentation projects" -optional = false -python-versions = ">=3.5" -files = [ - {file = "sphinx_reredirects-0.1.2-py3-none-any.whl", hash = "sha256:3a22161771aadd448bb608a4fe7277252182a337af53c18372b7104531d71489"}, - {file = "sphinx_reredirects-0.1.2.tar.gz", hash = "sha256:a0e7213304759b01edc22f032f1715a1c61176fc8f167164e7a52b9feec9ac64"}, -] - -[package.dependencies] -sphinx = "*" - -[[package]] -name = "sphinx-resolve-py-references" -version = "0.1.0" -description = "Better python object resolution in Sphinx" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinx_resolve_py_references-0.1.0-py2.py3-none-any.whl", hash = "sha256:ccf44a6b62d75c3a568285f4e1815734088c1a7cab7bbb7935bb22fbf0d78bc2"}, - {file = "sphinx_resolve_py_references-0.1.0.tar.gz", hash = "sha256:0f87c06b29ec128964aee2e40d170d1d3c0e5f4955b2618a89ca724f42385372"}, -] - -[package.dependencies] -sphinx = "*" - -[[package]] -name = "sphinxcontrib-applehelp" -version = "1.0.4" -description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, - {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -optional = false -python-versions = ">=3.5" -files = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.0.1" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, - {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["html5lib", "pytest"] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -optional = false -python-versions = ">=3.5" -files = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] - -[package.extras] -test = ["flake8", "mypy", "pytest"] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -optional = false -python-versions = ">=3.5" -files = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "1.1.5" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -optional = false -python-versions = ">=3.5" -files = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxext-opengraph" -version = "0.8.2" -description = "Sphinx Extension to enable OGP support" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinxext-opengraph-0.8.2.tar.gz", hash = "sha256:45a693b6704052c426576f0a1f630649c55b4188bc49eb63e9587e24a923db39"}, - {file = "sphinxext_opengraph-0.8.2-py3-none-any.whl", hash = "sha256:6a05bdfe5176d9dd0a1d58a504f17118362ab976631213cd36fb44c4c40544c9"}, -] - -[package.dependencies] -matplotlib = "*" -sphinx = ">=4.0" - -[[package]] -name = "starlette" -version = "0.27.0" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.7" -files = [ - {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, - {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, -] - -[package.dependencies] -anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} - -[package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] - -[[package]] -name = "tornado" -version = "6.3.2" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">= 3.8" -files = [ - {file = "tornado-6.3.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c367ab6c0393d71171123ca5515c61ff62fe09024fa6bf299cd1339dc9456829"}, - {file = "tornado-6.3.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b46a6ab20f5c7c1cb949c72c1994a4585d2eaa0be4853f50a03b5031e964fc7c"}, - {file = "tornado-6.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2de14066c4a38b4ecbbcd55c5cc4b5340eb04f1c5e81da7451ef555859c833f"}, - {file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05615096845cf50a895026f749195bf0b10b8909f9be672f50b0fe69cba368e4"}, - {file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b17b1cf5f8354efa3d37c6e28fdfd9c1c1e5122f2cb56dac121ac61baa47cbe"}, - {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:29e71c847a35f6e10ca3b5c2990a52ce38b233019d8e858b755ea6ce4dcdd19d"}, - {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:834ae7540ad3a83199a8da8f9f2d383e3c3d5130a328889e4cc991acc81e87a0"}, - {file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6a0848f1aea0d196a7c4f6772197cbe2abc4266f836b0aac76947872cd29b411"}, - {file = "tornado-6.3.2-cp38-abi3-win32.whl", hash = "sha256:7efcbcc30b7c654eb6a8c9c9da787a851c18f8ccd4a5a3a95b05c7accfa068d2"}, - {file = "tornado-6.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:0c325e66c8123c606eea33084976c832aa4e766b7dff8aedd7587ea44a604cdf"}, - {file = "tornado-6.3.2.tar.gz", hash = "sha256:4b927c4f19b71e627b13f3db2324e4ae660527143f9e1f2e2fb404f3a187e2ba"}, -] - -[[package]] -name = "tracerite" -version = "1.1.0" -description = "Human-readable HTML tracebacks for Python exceptions" -optional = false -python-versions = "*" -files = [ - {file = "tracerite-1.1.0-py3-none-any.whl", hash = "sha256:4cccac04db05eeeabda45e72b57199e147fa2f73cf64d89cfd625df321bd2ab6"}, - {file = "tracerite-1.1.0.tar.gz", hash = "sha256:041dab8fd4bb405f73506293ac7438a2d311e5f9044378ba7d9a6540392f9e4b"}, -] - -[package.dependencies] -html5tagger = ">=1.2.1" - -[[package]] -name = "typing-extensions" -version = "4.6.3" -description = "Backported and Experimental Type Hints for Python 3.7+" -optional = false -python-versions = ">=3.7" -files = [ - {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, - {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, -] - -[[package]] -name = "ujson" -version = "5.7.0" -description = "Ultra fast JSON encoder and decoder for Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "ujson-5.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5eba5e69e4361ac3a311cf44fa71bc619361b6e0626768a494771aacd1c2f09b"}, - {file = "ujson-5.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aae4d9e1b4c7b61780f0a006c897a4a1904f862fdab1abb3ea8f45bd11aa58f3"}, - {file = "ujson-5.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2e43ccdba1cb5c6d3448eadf6fc0dae7be6c77e357a3abc968d1b44e265866d"}, - {file = "ujson-5.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54384ce4920a6d35fa9ea8e580bc6d359e3eb961fa7e43f46c78e3ed162d56ff"}, - {file = "ujson-5.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24ad1aa7fc4e4caa41d3d343512ce68e41411fb92adf7f434a4d4b3749dc8f58"}, - {file = "ujson-5.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:afff311e9f065a8f03c3753db7011bae7beb73a66189c7ea5fcb0456b7041ea4"}, - {file = "ujson-5.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e80f0d03e7e8646fc3d79ed2d875cebd4c83846e129737fdc4c2532dbd43d9e"}, - {file = "ujson-5.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:137831d8a0db302fb6828ee21c67ad63ac537bddc4376e1aab1c8573756ee21c"}, - {file = "ujson-5.7.0-cp310-cp310-win32.whl", hash = "sha256:7df3fd35ebc14dafeea031038a99232b32f53fa4c3ecddb8bed132a43eefb8ad"}, - {file = "ujson-5.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:af4639f684f425177d09ae409c07602c4096a6287027469157bfb6f83e01448b"}, - {file = "ujson-5.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b0f2680ce8a70f77f5d70aaf3f013d53e6af6d7058727a35d8ceb4a71cdd4e9"}, - {file = "ujson-5.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a19fd8e7d8cc58a169bea99fed5666023adf707a536d8f7b0a3c51dd498abf"}, - {file = "ujson-5.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6abb8e6d8f1ae72f0ed18287245f5b6d40094e2656d1eab6d99d666361514074"}, - {file = "ujson-5.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8cd622c069368d5074bd93817b31bdb02f8d818e57c29e206f10a1f9c6337dd"}, - {file = "ujson-5.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14f9082669f90e18e64792b3fd0bf19f2b15e7fe467534a35ea4b53f3bf4b755"}, - {file = "ujson-5.7.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7ff6ebb43bc81b057724e89550b13c9a30eda0f29c2f506f8b009895438f5a6"}, - {file = "ujson-5.7.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f7f241488879d91a136b299e0c4ce091996c684a53775e63bb442d1a8e9ae22a"}, - {file = "ujson-5.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5593263a7fcfb934107444bcfba9dde8145b282de0ee9f61e285e59a916dda0f"}, - {file = "ujson-5.7.0-cp311-cp311-win32.whl", hash = "sha256:26c2b32b489c393106e9cb68d0a02e1a7b9d05a07429d875c46b94ee8405bdb7"}, - {file = "ujson-5.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ed24406454bb5a31df18f0a423ae14beb27b28cdfa34f6268e7ebddf23da807e"}, - {file = "ujson-5.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18679484e3bf9926342b1c43a3bd640f93a9eeeba19ef3d21993af7b0c44785d"}, - {file = "ujson-5.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ee295761e1c6c30400641f0a20d381633d7622633cdf83a194f3c876a0e4b7e"}, - {file = "ujson-5.7.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b738282e12a05f400b291966630a98d622da0938caa4bc93cf65adb5f4281c60"}, - {file = "ujson-5.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00343501dbaa5172e78ef0e37f9ebd08040110e11c12420ff7c1f9f0332d939e"}, - {file = "ujson-5.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c0d1f7c3908357ee100aa64c4d1cf91edf99c40ac0069422a4fd5fd23b263263"}, - {file = "ujson-5.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a5d2f44331cf04689eafac7a6596c71d6657967c07ac700b0ae1c921178645da"}, - {file = "ujson-5.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:16b2254a77b310f118717715259a196662baa6b1f63b1a642d12ab1ff998c3d7"}, - {file = "ujson-5.7.0-cp37-cp37m-win32.whl", hash = "sha256:6faf46fa100b2b89e4db47206cf8a1ffb41542cdd34dde615b2fc2288954f194"}, - {file = "ujson-5.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ff0004c3f5a9a6574689a553d1b7819d1a496b4f005a7451f339dc2d9f4cf98c"}, - {file = "ujson-5.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:75204a1dd7ec6158c8db85a2f14a68d2143503f4bafb9a00b63fe09d35762a5e"}, - {file = "ujson-5.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7312731c7826e6c99cdd3ac503cd9acd300598e7a80bcf41f604fee5f49f566c"}, - {file = "ujson-5.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b9dc5a90e2149643df7f23634fe202fed5ebc787a2a1be95cf23632b4d90651"}, - {file = "ujson-5.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6a6961fc48821d84b1198a09516e396d56551e910d489692126e90bf4887d29"}, - {file = "ujson-5.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b01a9af52a0d5c46b2c68e3f258fdef2eacaa0ce6ae3e9eb97983f5b1166edb6"}, - {file = "ujson-5.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7316d3edeba8a403686cdcad4af737b8415493101e7462a70ff73dd0609eafc"}, - {file = "ujson-5.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ee997799a23227e2319a3f8817ce0b058923dbd31904761b788dc8f53bd3e30"}, - {file = "ujson-5.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dda9aa4c33435147262cd2ea87c6b7a1ca83ba9b3933ff7df34e69fee9fced0c"}, - {file = "ujson-5.7.0-cp38-cp38-win32.whl", hash = "sha256:bea8d30e362180aafecabbdcbe0e1f0b32c9fa9e39c38e4af037b9d3ca36f50c"}, - {file = "ujson-5.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:c96e3b872bf883090ddf32cc41957edf819c5336ab0007d0cf3854e61841726d"}, - {file = "ujson-5.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6411aea4c94a8e93c2baac096fbf697af35ba2b2ed410b8b360b3c0957a952d3"}, - {file = "ujson-5.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d3b3499c55911f70d4e074c626acdb79a56f54262c3c83325ffb210fb03e44d"}, - {file = "ujson-5.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341f891d45dd3814d31764626c55d7ab3fd21af61fbc99d070e9c10c1190680b"}, - {file = "ujson-5.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f242eec917bafdc3f73a1021617db85f9958df80f267db69c76d766058f7b19"}, - {file = "ujson-5.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3af9f9f22a67a8c9466a32115d9073c72a33ae627b11de6f592df0ee09b98b6"}, - {file = "ujson-5.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4a3d794afbf134df3056a813e5c8a935208cddeae975bd4bc0ef7e89c52f0ce0"}, - {file = "ujson-5.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:800bf998e78dae655008dd10b22ca8dc93bdcfcc82f620d754a411592da4bbf2"}, - {file = "ujson-5.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b5ac3d5c5825e30b438ea92845380e812a476d6c2a1872b76026f2e9d8060fc2"}, - {file = "ujson-5.7.0-cp39-cp39-win32.whl", hash = "sha256:cd90027e6d93e8982f7d0d23acf88c896d18deff1903dd96140613389b25c0dd"}, - {file = "ujson-5.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:523ee146cdb2122bbd827f4dcc2a8e66607b3f665186bce9e4f78c9710b6d8ab"}, - {file = "ujson-5.7.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e87cec407ec004cf1b04c0ed7219a68c12860123dfb8902ef880d3d87a71c172"}, - {file = "ujson-5.7.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bab10165db6a7994e67001733f7f2caf3400b3e11538409d8756bc9b1c64f7e8"}, - {file = "ujson-5.7.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b522be14a28e6ac1cf818599aeff1004a28b42df4ed4d7bc819887b9dac915fc"}, - {file = "ujson-5.7.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7592f40175c723c032cdbe9fe5165b3b5903604f774ab0849363386e99e1f253"}, - {file = "ujson-5.7.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ed22f9665327a981f288a4f758a432824dc0314e4195a0eaeb0da56a477da94d"}, - {file = "ujson-5.7.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:adf445a49d9a97a5a4c9bb1d652a1528de09dd1c48b29f79f3d66cea9f826bf6"}, - {file = "ujson-5.7.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64772a53f3c4b6122ed930ae145184ebaed38534c60f3d859d8c3f00911eb122"}, - {file = "ujson-5.7.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35209cb2c13fcb9d76d249286105b4897b75a5e7f0efb0c0f4b90f222ce48910"}, - {file = "ujson-5.7.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90712dfc775b2c7a07d4d8e059dd58636bd6ff1776d79857776152e693bddea6"}, - {file = "ujson-5.7.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0e4e8981c6e7e9e637e637ad8ffe948a09e5434bc5f52ecbb82b4b4cfc092bfb"}, - {file = "ujson-5.7.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:581c945b811a3d67c27566539bfcb9705ea09cb27c4be0002f7a553c8886b817"}, - {file = "ujson-5.7.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d36a807a24c7d44f71686685ae6fbc8793d784bca1adf4c89f5f780b835b6243"}, - {file = "ujson-5.7.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b4257307e3662aa65e2644a277ca68783c5d51190ed9c49efebdd3cbfd5fa44"}, - {file = "ujson-5.7.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea7423d8a2f9e160c5e011119741682414c5b8dce4ae56590a966316a07a4618"}, - {file = "ujson-5.7.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4c592eb91a5968058a561d358d0fef59099ed152cfb3e1cd14eee51a7a93879e"}, - {file = "ujson-5.7.0.tar.gz", hash = "sha256:e788e5d5dcae8f6118ac9b45d0b891a0d55f7ac480eddcb7f07263f2bcf37b23"}, -] - -[[package]] -name = "urllib3" -version = "2.0.2" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.7" -files = [ - {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, - {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "uvicorn" -version = "0.22.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.7" -files = [ - {file = "uvicorn-0.22.0-py3-none-any.whl", hash = "sha256:e9434d3bbf05f310e762147f769c9f21235ee118ba2d2bf1155a7196448bd996"}, - {file = "uvicorn-0.22.0.tar.gz", hash = "sha256:79277ae03db57ce7d9aa0567830bbb51d7a612f54d6e1e3e92da3ef24c2c8ed8"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} -h11 = ">=0.8" -httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} -python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} -watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "uvloop" -version = "0.17.0" -description = "Fast implementation of asyncio event loop on top of libuv" -optional = false -python-versions = ">=3.7" -files = [ - {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce9f61938d7155f79d3cb2ffa663147d4a76d16e08f65e2c66b77bd41b356718"}, - {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:68532f4349fd3900b839f588972b3392ee56042e440dd5873dfbbcd2cc67617c"}, - {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d"}, - {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff3d00b70ce95adce264462c930fbaecb29718ba6563db354608f37e49e09024"}, - {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a5abddb3558d3f0a78949c750644a67be31e47936042d4f6c888dd6f3c95f4aa"}, - {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8efcadc5a0003d3a6e887ccc1fb44dec25594f117a94e3127954c05cf144d811"}, - {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3378eb62c63bf336ae2070599e49089005771cc651c8769aaad72d1bd9385a7c"}, - {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6aafa5a78b9e62493539456f8b646f85abc7093dd997f4976bb105537cf2635e"}, - {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c686a47d57ca910a2572fddfe9912819880b8765e2f01dc0dd12a9bf8573e539"}, - {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:864e1197139d651a76c81757db5eb199db8866e13acb0dfe96e6fc5d1cf45fc4"}, - {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2a6149e1defac0faf505406259561bc14b034cdf1d4711a3ddcdfbaa8d825a05"}, - {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6708f30db9117f115eadc4f125c2a10c1a50d711461699a0cbfaa45b9a78e376"}, - {file = "uvloop-0.17.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:23609ca361a7fc587031429fa25ad2ed7242941adec948f9d10c045bfecab06b"}, - {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2deae0b0fb00a6af41fe60a675cec079615b01d68beb4cc7b722424406b126a8"}, - {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45cea33b208971e87a31c17622e4b440cac231766ec11e5d22c76fab3bf9df62"}, - {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9b09e0f0ac29eee0451d71798878eae5a4e6a91aa275e114037b27f7db72702d"}, - {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbbaf9da2ee98ee2531e0c780455f2841e4675ff580ecf93fe5c48fe733b5667"}, - {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a4aee22ece20958888eedbad20e4dbb03c37533e010fb824161b4f05e641f738"}, - {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:307958f9fc5c8bb01fad752d1345168c0abc5d62c1b72a4a8c6c06f042b45b20"}, - {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ebeeec6a6641d0adb2ea71dcfb76017602ee2bfd8213e3fcc18d8f699c5104f"}, - {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1436c8673c1563422213ac6907789ecb2b070f5939b9cbff9ef7113f2b531595"}, - {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8887d675a64cfc59f4ecd34382e5b4f0ef4ae1da37ed665adba0c2badf0d6578"}, - {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3db8de10ed684995a7f34a001f15b374c230f7655ae840964d51496e2f8a8474"}, - {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7d37dccc7ae63e61f7b96ee2e19c40f153ba6ce730d8ba4d3b4e9738c1dccc1b"}, - {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbbe908fda687e39afd6ea2a2f14c2c3e43f2ca88e3a11964b297822358d0e6c"}, - {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d97672dc709fa4447ab83276f344a165075fd9f366a97b712bdd3fee05efae8"}, - {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e507c9ee39c61bfddd79714e4f85900656db1aec4d40c6de55648e85c2799c"}, - {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c092a2c1e736086d59ac8e41f9c98f26bbf9b9222a76f21af9dfe949b99b2eb9"}, - {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded"}, - {file = "uvloop-0.17.0.tar.gz", hash = "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1"}, -] - -[package.extras] -dev = ["Cython (>=0.29.32,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=3.6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] - -[[package]] -name = "watchfiles" -version = "0.19.0" -description = "Simple, modern and high performance file watching and code reload in python." -optional = false -python-versions = ">=3.7" -files = [ - {file = "watchfiles-0.19.0-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:91633e64712df3051ca454ca7d1b976baf842d7a3640b87622b323c55f3345e7"}, - {file = "watchfiles-0.19.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b6577b8c6c8701ba8642ea9335a129836347894b666dd1ec2226830e263909d3"}, - {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:18b28f6ad871b82df9542ff958d0c86bb0d8310bb09eb8e87d97318a3b5273af"}, - {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fac19dc9cbc34052394dbe81e149411a62e71999c0a19e1e09ce537867f95ae0"}, - {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ea3397aecbc81c19ed7f025e051a7387feefdb789cf768ff994c1228182fda"}, - {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0376deac92377817e4fb8f347bf559b7d44ff556d9bc6f6208dd3f79f104aaf"}, - {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c75eff897786ee262c9f17a48886f4e98e6cfd335e011c591c305e5d083c056"}, - {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb5d45c4143c1dd60f98a16187fd123eda7248f84ef22244818c18d531a249d1"}, - {file = "watchfiles-0.19.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:79c533ff593db861ae23436541f481ec896ee3da4e5db8962429b441bbaae16e"}, - {file = "watchfiles-0.19.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3d7d267d27aceeeaa3de0dd161a0d64f0a282264d592e335fff7958cc0cbae7c"}, - {file = "watchfiles-0.19.0-cp37-abi3-win32.whl", hash = "sha256:176a9a7641ec2c97b24455135d58012a5be5c6217fc4d5fef0b2b9f75dbf5154"}, - {file = "watchfiles-0.19.0-cp37-abi3-win_amd64.whl", hash = "sha256:945be0baa3e2440151eb3718fd8846751e8b51d8de7b884c90b17d271d34cae8"}, - {file = "watchfiles-0.19.0-cp37-abi3-win_arm64.whl", hash = "sha256:0089c6dc24d436b373c3c57657bf4f9a453b13767150d17284fc6162b2791911"}, - {file = "watchfiles-0.19.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cae3dde0b4b2078f31527acff6f486e23abed307ba4d3932466ba7cdd5ecec79"}, - {file = "watchfiles-0.19.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f3920b1285a7d3ce898e303d84791b7bf40d57b7695ad549dc04e6a44c9f120"}, - {file = "watchfiles-0.19.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9afd0d69429172c796164fd7fe8e821ade9be983f51c659a38da3faaaaac44dc"}, - {file = "watchfiles-0.19.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68dce92b29575dda0f8d30c11742a8e2b9b8ec768ae414b54f7453f27bdf9545"}, - {file = "watchfiles-0.19.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:5569fc7f967429d4bc87e355cdfdcee6aabe4b620801e2cf5805ea245c06097c"}, - {file = "watchfiles-0.19.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5471582658ea56fca122c0f0d0116a36807c63fefd6fdc92c71ca9a4491b6b48"}, - {file = "watchfiles-0.19.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b538014a87f94d92f98f34d3e6d2635478e6be6423a9ea53e4dd96210065e193"}, - {file = "watchfiles-0.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20b44221764955b1e703f012c74015306fb7e79a00c15370785f309b1ed9aa8d"}, - {file = "watchfiles-0.19.0.tar.gz", hash = "sha256:d9b073073e048081e502b6c6b0b88714c026a1a4c890569238d04aca5f9ca74b"}, -] - -[package.dependencies] -anyio = ">=3.0.0" - -[[package]] -name = "websockets" -version = "11.0.3" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "websockets-11.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac"}, - {file = "websockets-11.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d"}, - {file = "websockets-11.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d27a4832cc1a0ee07cdcf2b0629a8a72db73f4cf6de6f0904f6661227f256f"}, - {file = "websockets-11.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffd7dcaf744f25f82190856bc26ed81721508fc5cbf2a330751e135ff1283564"}, - {file = "websockets-11.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7622a89d696fc87af8e8d280d9b421db5133ef5b29d3f7a1ce9f1a7bf7fcfa11"}, - {file = "websockets-11.0.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bceab846bac555aff6427d060f2fcfff71042dba6f5fca7dc4f75cac815e57ca"}, - {file = "websockets-11.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54c6e5b3d3a8936a4ab6870d46bdd6ec500ad62bde9e44462c32d18f1e9a8e54"}, - {file = "websockets-11.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:41f696ba95cd92dc047e46b41b26dd24518384749ed0d99bea0a941ca87404c4"}, - {file = "websockets-11.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86d2a77fd490ae3ff6fae1c6ceaecad063d3cc2320b44377efdde79880e11526"}, - {file = "websockets-11.0.3-cp310-cp310-win32.whl", hash = "sha256:2d903ad4419f5b472de90cd2d40384573b25da71e33519a67797de17ef849b69"}, - {file = "websockets-11.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:1d2256283fa4b7f4c7d7d3e84dc2ece74d341bce57d5b9bf385df109c2a1a82f"}, - {file = "websockets-11.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e848f46a58b9fcf3d06061d17be388caf70ea5b8cc3466251963c8345e13f7eb"}, - {file = "websockets-11.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa5003845cdd21ac0dc6c9bf661c5beddd01116f6eb9eb3c8e272353d45b3288"}, - {file = "websockets-11.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b58cbf0697721120866820b89f93659abc31c1e876bf20d0b3d03cef14faf84d"}, - {file = "websockets-11.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:660e2d9068d2bedc0912af508f30bbeb505bbbf9774d98def45f68278cea20d3"}, - {file = "websockets-11.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1f0524f203e3bd35149f12157438f406eff2e4fb30f71221c8a5eceb3617b6b"}, - {file = "websockets-11.0.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:def07915168ac8f7853812cc593c71185a16216e9e4fa886358a17ed0fd9fcf6"}, - {file = "websockets-11.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b30c6590146e53149f04e85a6e4fcae068df4289e31e4aee1fdf56a0dead8f97"}, - {file = "websockets-11.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:619d9f06372b3a42bc29d0cd0354c9bb9fb39c2cbc1a9c5025b4538738dbffaf"}, - {file = "websockets-11.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:01f5567d9cf6f502d655151645d4e8b72b453413d3819d2b6f1185abc23e82dd"}, - {file = "websockets-11.0.3-cp311-cp311-win32.whl", hash = "sha256:e1459677e5d12be8bbc7584c35b992eea142911a6236a3278b9b5ce3326f282c"}, - {file = "websockets-11.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:e7837cb169eca3b3ae94cc5787c4fed99eef74c0ab9506756eea335e0d6f3ed8"}, - {file = "websockets-11.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9f59a3c656fef341a99e3d63189852be7084c0e54b75734cde571182c087b152"}, - {file = "websockets-11.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2529338a6ff0eb0b50c7be33dc3d0e456381157a31eefc561771ee431134a97f"}, - {file = "websockets-11.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34fd59a4ac42dff6d4681d8843217137f6bc85ed29722f2f7222bd619d15e95b"}, - {file = "websockets-11.0.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:332d126167ddddec94597c2365537baf9ff62dfcc9db4266f263d455f2f031cb"}, - {file = "websockets-11.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6505c1b31274723ccaf5f515c1824a4ad2f0d191cec942666b3d0f3aa4cb4007"}, - {file = "websockets-11.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f467ba0050b7de85016b43f5a22b46383ef004c4f672148a8abf32bc999a87f0"}, - {file = "websockets-11.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9d9acd80072abcc98bd2c86c3c9cd4ac2347b5a5a0cae7ed5c0ee5675f86d9af"}, - {file = "websockets-11.0.3-cp37-cp37m-win32.whl", hash = "sha256:e590228200fcfc7e9109509e4d9125eace2042fd52b595dd22bbc34bb282307f"}, - {file = "websockets-11.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:b16fff62b45eccb9c7abb18e60e7e446998093cdcb50fed33134b9b6878836de"}, - {file = "websockets-11.0.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fb06eea71a00a7af0ae6aefbb932fb8a7df3cb390cc217d51a9ad7343de1b8d0"}, - {file = "websockets-11.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8a34e13a62a59c871064dfd8ffb150867e54291e46d4a7cf11d02c94a5275bae"}, - {file = "websockets-11.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4841ed00f1026dfbced6fca7d963c4e7043aa832648671b5138008dc5a8f6d99"}, - {file = "websockets-11.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a073fc9ab1c8aff37c99f11f1641e16da517770e31a37265d2755282a5d28aa"}, - {file = "websockets-11.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68b977f21ce443d6d378dbd5ca38621755f2063d6fdb3335bda981d552cfff86"}, - {file = "websockets-11.0.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a99a7a71631f0efe727c10edfba09ea6bee4166a6f9c19aafb6c0b5917d09c"}, - {file = "websockets-11.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bee9fcb41db2a23bed96c6b6ead6489702c12334ea20a297aa095ce6d31370d0"}, - {file = "websockets-11.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4b253869ea05a5a073ebfdcb5cb3b0266a57c3764cf6fe114e4cd90f4bfa5f5e"}, - {file = "websockets-11.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1553cb82942b2a74dd9b15a018dce645d4e68674de2ca31ff13ebc2d9f283788"}, - {file = "websockets-11.0.3-cp38-cp38-win32.whl", hash = "sha256:f61bdb1df43dc9c131791fbc2355535f9024b9a04398d3bd0684fc16ab07df74"}, - {file = "websockets-11.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:03aae4edc0b1c68498f41a6772d80ac7c1e33c06c6ffa2ac1c27a07653e79d6f"}, - {file = "websockets-11.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:777354ee16f02f643a4c7f2b3eff8027a33c9861edc691a2003531f5da4f6bc8"}, - {file = "websockets-11.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8c82f11964f010053e13daafdc7154ce7385ecc538989a354ccc7067fd7028fd"}, - {file = "websockets-11.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3580dd9c1ad0701169e4d6fc41e878ffe05e6bdcaf3c412f9d559389d0c9e016"}, - {file = "websockets-11.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f1a3f10f836fab6ca6efa97bb952300b20ae56b409414ca85bff2ad241d2a61"}, - {file = "websockets-11.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df41b9bc27c2c25b486bae7cf42fccdc52ff181c8c387bfd026624a491c2671b"}, - {file = "websockets-11.0.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:279e5de4671e79a9ac877427f4ac4ce93751b8823f276b681d04b2156713b9dd"}, - {file = "websockets-11.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1fdf26fa8a6a592f8f9235285b8affa72748dc12e964a5518c6c5e8f916716f7"}, - {file = "websockets-11.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:69269f3a0b472e91125b503d3c0b3566bda26da0a3261c49f0027eb6075086d1"}, - {file = "websockets-11.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:97b52894d948d2f6ea480171a27122d77af14ced35f62e5c892ca2fae9344311"}, - {file = "websockets-11.0.3-cp39-cp39-win32.whl", hash = "sha256:c7f3cb904cce8e1be667c7e6fef4516b98d1a6a0635a58a57528d577ac18a128"}, - {file = "websockets-11.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c792ea4eabc0159535608fc5658a74d1a81020eb35195dd63214dcf07556f67e"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f2e58f2c36cc52d41f2659e4c0cbf7353e28c8c9e63e30d8c6d3494dc9fdedcf"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de36fe9c02995c7e6ae6efe2e205816f5f00c22fd1fbf343d4d18c3d5ceac2f5"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ac56b661e60edd453585f4bd68eb6a29ae25b5184fd5ba51e97652580458998"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e052b8467dd07d4943936009f46ae5ce7b908ddcac3fda581656b1b19c083d9b"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:42cc5452a54a8e46a032521d7365da775823e21bfba2895fb7b77633cce031bb"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e6316827e3e79b7b8e7d8e3b08f4e331af91a48e794d5d8b099928b6f0b85f20"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8531fdcad636d82c517b26a448dcfe62f720e1922b33c81ce695d0edb91eb931"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c114e8da9b475739dde229fd3bc6b05a6537a88a578358bc8eb29b4030fac9c9"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e063b1865974611313a3849d43f2c3f5368093691349cf3c7c8f8f75ad7cb280"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:92b2065d642bf8c0a82d59e59053dd2fdde64d4ed44efe4870fa816c1232647b"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0ee68fe502f9031f19d495dae2c268830df2760c0524cbac5d759921ba8c8e82"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcacf2c7a6c3a84e720d1bb2b543c675bf6c40e460300b628bab1b1efc7c034c"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b67c6f5e5a401fc56394f191f00f9b3811fe843ee93f4a70df3c389d1adf857d"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d5023a4b6a5b183dc838808087033ec5df77580485fc533e7dab2567851b0a4"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ed058398f55163a79bb9f06a90ef9ccc063b204bb346c4de78efc5d15abfe602"}, - {file = "websockets-11.0.3-py3-none-any.whl", hash = "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6"}, - {file = "websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016"}, -] - -[[package]] -name = "werkzeug" -version = "2.1.2" -description = "The comprehensive WSGI web application library." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Werkzeug-2.1.2-py3-none-any.whl", hash = "sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255"}, - {file = "Werkzeug-2.1.2.tar.gz", hash = "sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6"}, -] - -[package.extras] -watchdog = ["watchdog"] - -[[package]] -name = "wsproto" -version = "1.2.0" -description = "WebSockets state-machine based protocol implementation" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, - {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, -] - -[package.dependencies] -h11 = ">=0.9.0,<1" - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.7" -files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "629118cfac10f1dab4c39c6ccd50bd69ca68a7fc05dd2baf1d020082d6b19e4e" diff --git a/docs/pyproject.toml b/docs/pyproject.toml deleted file mode 100644 index d2f47c577..000000000 --- a/docs/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[tool.poetry] -name = "docs" -version = "0.0.0" -description = "docs" -authors = ["rmorshea <ryan.morshead@gmail.com>"] -readme = "README.md" - -[tool.poetry.dependencies] -python = "^3.9" -reactpy = { path = "../src/py/reactpy", extras = ["starlette", "sanic", "fastapi", "flask", "tornado", "testing"], develop = false } -furo = "2022.04.07" -sphinx = "*" -sphinx-autodoc-typehints = "*" -sphinx-copybutton = "*" -sphinx-autobuild = "*" -sphinx-reredirects = "*" -sphinx-design = "*" -sphinx-resolve-py-references = "*" -sphinxext-opengraph = "*" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..28f64bf4d --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,12 @@ +mkdocs +mkdocs-git-revision-date-localized-plugin +mkdocs-material==9.4.0 +mkdocs-include-markdown-plugin +mkdocs-spellcheck[all] +mkdocs-git-authors-plugin +mkdocs-minify-plugin +mike +mkdocstrings[python] +black +linkcheckmd +mkdocs-section-index diff --git a/docs/source/_custom_js/README.md b/docs/source/_custom_js/README.md deleted file mode 100644 index 4d5d75dc2..000000000 --- a/docs/source/_custom_js/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Custom Javascript for ReactPy's Docs - -Build the javascript with - -``` -npm run build -``` - -This will drop a javascript bundle into `../_static/custom.js` diff --git a/docs/source/_custom_js/package-lock.json b/docs/source/_custom_js/package-lock.json deleted file mode 100644 index 98cbb7014..000000000 --- a/docs/source/_custom_js/package-lock.json +++ /dev/null @@ -1,766 +0,0 @@ -{ - "name": "reactpy-docs-example-loader", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "reactpy-docs-example-loader", - "version": "1.0.0", - "dependencies": { - "@reactpy/client": "file:../../../src/js/packages/@reactpy/client" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^21.0.1", - "@rollup/plugin-node-resolve": "^13.1.1", - "@rollup/plugin-replace": "^3.0.0", - "prettier": "^2.2.1", - "rollup": "^2.35.1" - } - }, - "../../../src/client/packages/@reactpy/client": { - "version": "0.3.1", - "integrity": "sha512-pIK5eNwFSHKXg7ClpASWFVKyZDYxz59MSFpVaX/OqJFkrJaAxBuhKGXNTMXmuyWOL5Iyvb/ErwwDRxQRzMNkfQ==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "../../../src/client/packages/client": { - "name": "@reactpy/client", - "version": "0.2.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "event-to-object": "^0.1.0", - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "prettier": "^3.0.0-alpha.6", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "../../../src/js/packages/@reactpy/client": { - "version": "0.3.1", - "license": "MIT", - "dependencies": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "node_modules/@reactpy/client": { - "resolved": "../../../src/js/packages/@reactpy/client", - "link": true - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz", - "integrity": "sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "commondir": "^1.0.1", - "estree-walker": "^2.0.1", - "glob": "^7.1.6", - "is-reference": "^1.2.1", - "magic-string": "^0.25.7", - "resolve": "^1.17.0" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^2.38.3" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.1.tgz", - "integrity": "sha512-6QKtRevXLrmEig9UiMYt2fSvee9TyltGRfw+qSs6xjUnxwjOzTOqy+/Lpxsgjb8mJn1EQNbCDAvt89O4uzL5kw==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "rollup": "^2.42.0" - } - }, - "node_modules/@rollup/plugin-node-resolve/node_modules/@types/resolve": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", - "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@rollup/plugin-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-3.0.0.tgz", - "integrity": "sha512-3c7JCbMuYXM4PbPWT4+m/4Y6U60SgsnDT/cCyAyUKwFHg7pTSfsSQzIpETha3a3ig6OdOKzZz87D9ZXIK3qsDg==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@rollup/pluginutils/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true - }, - "node_modules/@types/estree": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", - "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", - "dev": true - }, - "node_modules/@types/node": { - "version": "15.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", - "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/builtin-modules": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", - "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-core-module": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", - "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/magic-string": { - "version": "0.25.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", - "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "dev": true, - "dependencies": { - "sourcemap-codec": "^1.4.4" - } - }, - "node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prettier": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", - "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", - "dev": true, - "dependencies": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rollup": { - "version": "2.52.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.1.tgz", - "integrity": "sha512-/SPqz8UGnp4P1hq6wc9gdTqA2bXQXGx13TtoL03GBm6qGRI6Hm3p4Io7GeiHNLl0BsQAne1JNYY+q/apcY933w==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "dev": true - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - } - }, - "dependencies": { - "@reactpy/client": { - "version": "file:../../../src/js/packages/@reactpy/client", - "requires": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2", - "typescript": "^4.9.5" - } - }, - "@rollup/plugin-commonjs": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz", - "integrity": "sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^3.1.0", - "commondir": "^1.0.1", - "estree-walker": "^2.0.1", - "glob": "^7.1.6", - "is-reference": "^1.2.1", - "magic-string": "^0.25.7", - "resolve": "^1.17.0" - }, - "dependencies": { - "estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - } - } - }, - "@rollup/plugin-node-resolve": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.1.tgz", - "integrity": "sha512-6QKtRevXLrmEig9UiMYt2fSvee9TyltGRfw+qSs6xjUnxwjOzTOqy+/Lpxsgjb8mJn1EQNbCDAvt89O4uzL5kw==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "builtin-modules": "^3.1.0", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "dependencies": { - "@types/resolve": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", - "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", - "dev": true, - "requires": { - "@types/node": "*" - } - } - } - }, - "@rollup/plugin-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-3.0.0.tgz", - "integrity": "sha512-3c7JCbMuYXM4PbPWT4+m/4Y6U60SgsnDT/cCyAyUKwFHg7pTSfsSQzIpETha3a3ig6OdOKzZz87D9ZXIK3qsDg==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - } - }, - "@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "requires": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "dependencies": { - "@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true - }, - "estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true - } - } - }, - "@types/estree": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz", - "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==", - "dev": true - }, - "@types/node": { - "version": "15.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz", - "integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "builtin-modules": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", - "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", - "dev": true - }, - "commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-core-module": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", - "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", - "dev": true - }, - "is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "requires": { - "@types/estree": "*" - } - }, - "magic-string": { - "version": "0.25.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", - "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "dev": true, - "requires": { - "sourcemap-codec": "^1.4.4" - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", - "dev": true - }, - "prettier": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz", - "integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==", - "dev": true - }, - "resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", - "dev": true, - "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - } - }, - "rollup": { - "version": "2.52.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.1.tgz", - "integrity": "sha512-/SPqz8UGnp4P1hq6wc9gdTqA2bXQXGx13TtoL03GBm6qGRI6Hm3p4Io7GeiHNLl0BsQAne1JNYY+q/apcY933w==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - } - } -} diff --git a/docs/source/_custom_js/package.json b/docs/source/_custom_js/package.json deleted file mode 100644 index 78d72b961..000000000 --- a/docs/source/_custom_js/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "reactpy-docs-example-loader", - "version": "1.0.0", - "description": "simple javascript client for ReactPy's documentation", - "main": "index.js", - "scripts": { - "build": "rollup --config", - "format": "prettier --ignore-path .gitignore --write ." - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^21.0.1", - "@rollup/plugin-node-resolve": "^13.1.1", - "@rollup/plugin-replace": "^3.0.0", - "prettier": "^2.2.1", - "rollup": "^2.35.1" - }, - "dependencies": { - "@reactpy/client": "file:../../../src/js/packages/@reactpy/client" - } -} diff --git a/docs/source/_custom_js/rollup.config.js b/docs/source/_custom_js/rollup.config.js deleted file mode 100644 index 48dd535cf..000000000 --- a/docs/source/_custom_js/rollup.config.js +++ /dev/null @@ -1,26 +0,0 @@ -import resolve from "@rollup/plugin-node-resolve"; -import commonjs from "@rollup/plugin-commonjs"; -import replace from "@rollup/plugin-replace"; - -export default { - input: "src/index.js", - output: { - file: "../_static/custom.js", - format: "esm", - }, - plugins: [ - resolve(), - commonjs(), - replace({ - "process.env.NODE_ENV": JSON.stringify("production"), - preventAssignment: true, - }), - ], - onwarn: function (warning) { - if (warning.code === "THIS_IS_UNDEFINED") { - // skip warning where `this` is undefined at the top level of a module - return; - } - console.warn(warning.message); - }, -}; diff --git a/docs/source/_custom_js/src/index.js b/docs/source/_custom_js/src/index.js deleted file mode 100644 index 505adedd0..000000000 --- a/docs/source/_custom_js/src/index.js +++ /dev/null @@ -1,94 +0,0 @@ -import { SimpleReactPyClient, mount } from "@reactpy/client"; - -let didMountDebug = false; - -export function mountWidgetExample( - mountID, - viewID, - reactpyServerHost, - useActivateButton, -) { - let reactpyHost, reactpyPort; - if (reactpyServerHost) { - [reactpyHost, reactpyPort] = reactpyServerHost.split(":", 2); - } else { - reactpyHost = window.location.hostname; - reactpyPort = window.location.port; - } - - const client = new SimpleReactPyClient({ - serverLocation: { - url: `${window.location.protocol}//${reactpyHost}:${reactpyPort}`, - route: "/", - query: `?view_id=${viewID}`, - }, - }); - - const mountEl = document.getElementById(mountID); - let isMounted = false; - triggerIfInViewport(mountEl, () => { - if (!isMounted) { - activateView(mountEl, client, useActivateButton); - isMounted = true; - } - }); -} - -function activateView(mountEl, client, useActivateButton) { - if (!useActivateButton) { - mount(mountEl, client); - return; - } - - const enableWidgetButton = document.createElement("button"); - enableWidgetButton.appendChild(document.createTextNode("Activate")); - enableWidgetButton.setAttribute("class", "enable-widget-button"); - - enableWidgetButton.addEventListener("click", () => - fadeOutElementThenCallback(enableWidgetButton, () => { - { - mountEl.removeChild(enableWidgetButton); - mountEl.setAttribute("class", "interactive widget-container"); - mountWithLayoutServer(mountEl, serverInfo); - } - }), - ); - - function fadeOutElementThenCallback(element, callback) { - { - var op = 1; // initial opacity - var timer = setInterval(function () { - { - if (op < 0.001) { - { - clearInterval(timer); - element.style.display = "none"; - callback(); - } - } - element.style.opacity = op; - element.style.filter = "alpha(opacity=" + op * 100 + ")"; - op -= op * 0.5; - } - }, 50); - } - } - - mountEl.appendChild(enableWidgetButton); -} - -function triggerIfInViewport(element, callback) { - const observer = new window.IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - callback(); - } - }, - { - root: null, - threshold: 0.1, // set offset 0.1 means trigger if at least 10% of element in viewport - }, - ); - - observer.observe(element); -} diff --git a/docs/source/_exts/async_doctest.py b/docs/source/_exts/async_doctest.py deleted file mode 100644 index 96024d488..000000000 --- a/docs/source/_exts/async_doctest.py +++ /dev/null @@ -1,47 +0,0 @@ -from doctest import DocTest, DocTestRunner -from textwrap import indent -from typing import Any - -from sphinx.application import Sphinx -from sphinx.ext.doctest import DocTestBuilder -from sphinx.ext.doctest import setup as doctest_setup - -test_template = """ -import asyncio as __test_template_asyncio - -async def __test_template__main(): - - {test} - - globals().update(locals()) - -__test_template_asyncio.run(__test_template__main()) -""" - - -class TestRunnerWrapper: - def __init__(self, runner: DocTestRunner): - self._runner = runner - - def __getattr__(self, name: str) -> Any: - return getattr(self._runner, name) - - def run(self, test: DocTest, *args: Any, **kwargs: Any) -> Any: - for ex in test.examples: - ex.source = test_template.format(test=indent(ex.source, " ").strip()) - return self._runner.run(test, *args, **kwargs) - - -class AsyncDoctestBuilder(DocTestBuilder): - @property - def test_runner(self) -> DocTestRunner: - return self._test_runner - - @test_runner.setter - def test_runner(self, value: DocTestRunner) -> None: - self._test_runner = TestRunnerWrapper(value) - - -def setup(app: Sphinx) -> None: - doctest_setup(app) - app.add_builder(AsyncDoctestBuilder, override=True) diff --git a/docs/source/_exts/autogen_api_docs.py b/docs/source/_exts/autogen_api_docs.py deleted file mode 100644 index b95d85a99..000000000 --- a/docs/source/_exts/autogen_api_docs.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -import sys -from collections.abc import Collection, Iterator -from pathlib import Path - -from sphinx.application import Sphinx - -HERE = Path(__file__).parent -SRC = HERE.parent.parent.parent / "src" -PYTHON_PACKAGE = SRC / "py" / "reactpy" / "reactpy" - -AUTO_DIR = HERE.parent / "_auto" -AUTO_DIR.mkdir(exist_ok=True) - -API_FILE = AUTO_DIR / "apis.rst" - -# All valid RST section symbols - it shouldn't be realistically possible to exhaust them -SECTION_SYMBOLS = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" - -AUTODOC_TEMPLATE_WITH_MEMBERS = """\ -.. automodule:: {module} - :members: - :ignore-module-all: -""" - -AUTODOC_TEMPLATE_WITHOUT_MEMBERS = """\ -.. automodule:: {module} - :ignore-module-all: -""" - -TITLE = """\ -========== -Python API -========== -""" - - -def generate_api_docs(): - content = [TITLE] - - for file in walk_python_files(PYTHON_PACKAGE, ignore_dirs={"__pycache__"}): - if file.name == "__init__.py": - if file.parent != PYTHON_PACKAGE: - content.append(make_package_section(file)) - else: - content.append(make_module_section(file)) - - API_FILE.write_text("\n".join(content)) - - -def make_package_section(file: Path) -> str: - parent_dir = file.parent - symbol = get_section_symbol(parent_dir) - section_name = f"``{parent_dir.name}``" - module_name = get_module_name(parent_dir) - return ( - section_name - + "\n" - + (symbol * len(section_name)) - + "\n" - + AUTODOC_TEMPLATE_WITHOUT_MEMBERS.format(module=module_name) - ) - - -def make_module_section(file: Path) -> str: - symbol = get_section_symbol(file) - section_name = f"``{file.stem}``" - module_name = get_module_name(file) - return ( - section_name - + "\n" - + (symbol * len(section_name)) - + "\n" - + AUTODOC_TEMPLATE_WITH_MEMBERS.format(module=module_name) - ) - - -def get_module_name(path: Path) -> str: - return ".".join(path.with_suffix("").relative_to(PYTHON_PACKAGE.parent).parts) - - -def get_section_symbol(path: Path) -> str: - rel_path = path.relative_to(PYTHON_PACKAGE) - rel_path_parts = rel_path.parts - if len(rel_path_parts) > len(SECTION_SYMBOLS): - msg = f"package structure is too deep - ran out of section symbols: {rel_path}" - raise RuntimeError(msg) - return SECTION_SYMBOLS[len(rel_path_parts) - 1] - - -def walk_python_files(root: Path, ignore_dirs: Collection[str]) -> Iterator[Path]: - """Iterate over Python files - - We yield in a particular order to get the correction title section structure. Given - a directory structure of the form:: - - project/ - __init__.py - /package - __init__.py - module_a.py - module_b.py - - We yield the files in this order:: - - project/__init__.py - project/package/__init__.py - project/package/module_a.py - project/module_b.py - - In this way we generate the section titles in the appropriate order:: - - project - ======= - - project.package - --------------- - - project.package.module_a - ------------------------ - - """ - for path in sorted( - root.iterdir(), - key=lambda path: ( - # __init__.py files first - int(not path.name == "__init__.py"), - # then directories - int(not path.is_dir()), - # sort by file name last - path.name, - ), - ): - if path.is_dir(): - if (path / "__init__.py").exists() and path.name not in ignore_dirs: - yield from walk_python_files(path, ignore_dirs) - elif path.suffix == ".py": - yield path - - -def setup(app: Sphinx) -> None: - if sys.platform == "win32" and sys.version_info[:2] == (3, 7): - return None - generate_api_docs() - return None diff --git a/docs/source/_exts/build_custom_js.py b/docs/source/_exts/build_custom_js.py deleted file mode 100644 index 97857ba74..000000000 --- a/docs/source/_exts/build_custom_js.py +++ /dev/null @@ -1,12 +0,0 @@ -import subprocess -from pathlib import Path - -from sphinx.application import Sphinx - -SOURCE_DIR = Path(__file__).parent.parent -CUSTOM_JS_DIR = SOURCE_DIR / "_custom_js" - - -def setup(app: Sphinx) -> None: - subprocess.run("npm install", cwd=CUSTOM_JS_DIR, shell=True) # noqa S607 - subprocess.run("npm run build", cwd=CUSTOM_JS_DIR, shell=True) # noqa S607 diff --git a/docs/source/_exts/copy_vdom_json_schema.py b/docs/source/_exts/copy_vdom_json_schema.py deleted file mode 100644 index 38fc171ac..000000000 --- a/docs/source/_exts/copy_vdom_json_schema.py +++ /dev/null @@ -1,17 +0,0 @@ -import json -from pathlib import Path - -from sphinx.application import Sphinx - -from reactpy.core.vdom import VDOM_JSON_SCHEMA - - -def setup(app: Sphinx) -> None: - schema_file = Path(__file__).parent.parent / "vdom-json-schema.json" - current_schema = json.dumps(VDOM_JSON_SCHEMA, indent=2, sort_keys=True) - - # We need to make this check because the autoreload system for the docs checks - # to see if the file has changed to determine whether to re-build. Thus we should - # only write to the file if its contents will be different. - if not schema_file.exists() or schema_file.read_text() != current_schema: - schema_file.write_text(current_schema) diff --git a/docs/source/_exts/custom_autosectionlabel.py b/docs/source/_exts/custom_autosectionlabel.py deleted file mode 100644 index 92ff5e2df..000000000 --- a/docs/source/_exts/custom_autosectionlabel.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Mostly copied from sphinx.ext.autosectionlabel - -See Sphinx BSD license: -https://github.com/sphinx-doc/sphinx/blob/f9968594206e538f13fa1c27c065027f10d4ea27/LICENSE -""" - -from __future__ import annotations - -from fnmatch import fnmatch -from typing import Any, cast - -from docutils import nodes -from docutils.nodes import Node -from sphinx.application import Sphinx -from sphinx.domains.std import StandardDomain -from sphinx.locale import __ -from sphinx.util import logging -from sphinx.util.nodes import clean_astext - -logger = logging.getLogger(__name__) - - -def get_node_depth(node: Node) -> int: - i = 0 - cur_node = node - while cur_node.parent != node.document: - cur_node = cur_node.parent - i += 1 - return i - - -def register_sections_as_label(app: Sphinx, document: Node) -> None: - docname = app.env.docname - - for pattern in app.config.autosectionlabel_skip_docs: - if fnmatch(docname, pattern): - return None - - domain = cast(StandardDomain, app.env.get_domain("std")) - for node in document.traverse(nodes.section): - if ( - app.config.autosectionlabel_maxdepth - and get_node_depth(node) >= app.config.autosectionlabel_maxdepth - ): - continue - labelid = node["ids"][0] - - title = cast(nodes.title, node[0]) - ref_name = getattr(title, "rawsource", title.astext()) - if app.config.autosectionlabel_prefix_document: - name = nodes.fully_normalize_name(docname + ":" + ref_name) - else: - name = nodes.fully_normalize_name(ref_name) - sectname = clean_astext(title) - - if name in domain.labels: - logger.warning( - __("duplicate label %s, other instance in %s"), - name, - app.env.doc2path(domain.labels[name][0]), - location=node, - type="autosectionlabel", - subtype=docname, - ) - - domain.anonlabels[name] = docname, labelid - domain.labels[name] = docname, labelid, sectname - - -def setup(app: Sphinx) -> dict[str, Any]: - app.add_config_value("autosectionlabel_prefix_document", False, "env") - app.add_config_value("autosectionlabel_maxdepth", None, "env") - app.add_config_value("autosectionlabel_skip_docs", [], "env") - app.connect("doctree-read", register_sections_as_label) - - return { - "version": "builtin", - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/source/_exts/patched_html_translator.py b/docs/source/_exts/patched_html_translator.py deleted file mode 100644 index e2f8ed9a4..000000000 --- a/docs/source/_exts/patched_html_translator.py +++ /dev/null @@ -1,24 +0,0 @@ -from sphinx.util.docutils import is_html5_writer_available -from sphinx.writers.html import HTMLTranslator -from sphinx.writers.html5 import HTML5Translator - - -class PatchedHTMLTranslator( - HTML5Translator if is_html5_writer_available() else HTMLTranslator -): - def starttag(self, node, tagname, *args, **attrs): - if ( - tagname == "a" - and "target" not in attrs - and ( - "external" in attrs.get("class", "") - or "external" in attrs.get("classes", []) - ) - ): - attrs["target"] = "_blank" - attrs["ref"] = "noopener noreferrer" - return super().starttag(node, tagname, *args, **attrs) - - -def setup(app): - app.set_translator("html", PatchedHTMLTranslator) diff --git a/docs/source/_exts/reactpy_example.py b/docs/source/_exts/reactpy_example.py deleted file mode 100644 index c6b054c07..000000000 --- a/docs/source/_exts/reactpy_example.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path -from typing import Any - -from docs_app.examples import ( - SOURCE_DIR, - get_example_files_by_name, - get_normalized_example_name, -) -from docutils.parsers.rst import directives -from docutils.statemachine import StringList -from sphinx.application import Sphinx -from sphinx.util.docutils import SphinxDirective -from sphinx_design.tabs import TabSetDirective - - -class WidgetExample(SphinxDirective): - has_content = False - required_arguments = 1 - _next_id = 0 - - option_spec = { - "result-is-default-tab": directives.flag, - "activate-button": directives.flag, - } - - def run(self): - example_name = get_normalized_example_name( - self.arguments[0], - # only used if example name starts with "/" - self.get_source_info()[0], - ) - - show_linenos = "linenos" in self.options - live_example_is_default_tab = "result-is-default-tab" in self.options - activate_result = "activate-button" not in self.options - - ex_files = get_example_files_by_name(example_name) - if not ex_files: - src_file, line_num = self.get_source_info() - msg = f"Missing example named {example_name!r} referenced by document {src_file}:{line_num}" - raise ValueError(msg) - - labeled_tab_items: list[tuple[str, Any]] = [] - if len(ex_files) == 1: - labeled_tab_items.append( - ( - "main.py", - _literal_include( - path=ex_files[0], - linenos=show_linenos, - ), - ) - ) - else: - for path in sorted( - ex_files, key=lambda p: "" if p.name == "main.py" else p.name - ): - labeled_tab_items.append( - ( - path.name, - _literal_include( - path=path, - linenos=show_linenos, - ), - ) - ) - - result_tab_item = ( - "🚀 result", - _interactive_widget( - name=example_name, - with_activate_button=not activate_result, - ), - ) - if live_example_is_default_tab: - labeled_tab_items.insert(0, result_tab_item) - else: - labeled_tab_items.append(result_tab_item) - - return TabSetDirective( - "WidgetExample", - [], - {}, - _make_tab_items(labeled_tab_items), - self.lineno - 2, - self.content_offset, - "", - self.state, - self.state_machine, - ).run() - - -def _make_tab_items(labeled_content_tuples): - tab_items = "" - for label, content in labeled_content_tuples: - tab_items += _tab_item_template.format( - label=label, - content=content.replace("\n", "\n "), - ) - return _string_to_nested_lines(tab_items) - - -def _literal_include(path: Path, linenos: bool): - try: - language = { - ".py": "python", - ".js": "javascript", - ".json": "json", - }[path.suffix] - except KeyError: - msg = f"Unknown extension type {path.suffix!r}" - raise ValueError(msg) from None - - return _literal_include_template.format( - name=str(path.relative_to(SOURCE_DIR)), - language=language, - options=_join_options(_get_file_options(path)), - ) - - -def _join_options(option_strings: list[str]) -> str: - return "\n ".join(option_strings) - - -OPTION_PATTERN = re.compile(r"#\s:[\w-]+:.*") - - -def _get_file_options(file: Path) -> list[str]: - options = [] - - for line in file.read_text().split("\n"): - if not line.strip(): - continue - if not line.startswith("#"): - break - if not OPTION_PATTERN.match(line): - continue - option_string = line[1:].strip() - if option_string: - options.append(option_string) - - return options - - -def _interactive_widget(name, with_activate_button): - return _interactive_widget_template.format( - name=name, - activate_button_opt=":activate-button:" if with_activate_button else "", - ) - - -_tab_item_template = """ -.. tab-item:: {label} - - {content} -""" - - -_interactive_widget_template = """ -.. reactpy-view:: {name} - {activate_button_opt} -""" - - -_literal_include_template = """ -.. literalinclude:: /{name} - :language: {language} - {options} -""" - - -def _string_to_nested_lines(content): - return StringList(content.split("\n")) - - -def setup(app: Sphinx) -> None: - app.add_directive("reactpy", WidgetExample) diff --git a/docs/source/_exts/reactpy_view.py b/docs/source/_exts/reactpy_view.py deleted file mode 100644 index 7a2bf85a4..000000000 --- a/docs/source/_exts/reactpy_view.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import sys - -print(sys.path) - -from docs_app.examples import get_normalized_example_name -from docutils.nodes import raw -from docutils.parsers.rst import directives -from sphinx.application import Sphinx -from sphinx.util.docutils import SphinxDirective - -_REACTPY_EXAMPLE_HOST = os.environ.get("REACTPY_DOC_EXAMPLE_SERVER_HOST", "") -_REACTPY_STATIC_HOST = os.environ.get("REACTPY_DOC_STATIC_SERVER_HOST", "/docs").rstrip( - "/" -) - - -class IteractiveWidget(SphinxDirective): - has_content = False - required_arguments = 1 - _next_id = 0 - - option_spec = { - "activate-button": directives.flag, - "margin": float, - } - - def run(self): - IteractiveWidget._next_id += 1 - container_id = f"reactpy-widget-{IteractiveWidget._next_id}" - view_id = get_normalized_example_name( - self.arguments[0], - # only used if example name starts with "/" - self.get_source_info()[0], - ) - return [ - raw( - "", - f""" - <div> - <div - id="{container_id}" - class="interactive widget-container" - style="margin-bottom: {self.options.get("margin", 0)}px;" - /> - <script type="module"> - import {{ mountWidgetExample }} from "{_REACTPY_STATIC_HOST}/_static/custom.js"; - mountWidgetExample( - "{container_id}", - "{view_id}", - "{_REACTPY_EXAMPLE_HOST}", - {"true" if "activate-button" in self.options else "false"}, - ); - </script> - </div> - """, - format="html", - ) - ] - - -def setup(app: Sphinx) -> None: - app.add_directive("reactpy-view", IteractiveWidget) diff --git a/docs/source/_static/css/furo-theme-overrides.css b/docs/source/_static/css/furo-theme-overrides.css deleted file mode 100644 index a258e025e..000000000 --- a/docs/source/_static/css/furo-theme-overrides.css +++ /dev/null @@ -1,6 +0,0 @@ -.sidebar-container { - width: 18em; -} -.sidebar-brand-text { - display: none; -} diff --git a/docs/source/_static/css/larger-api-margins.css b/docs/source/_static/css/larger-api-margins.css deleted file mode 100644 index f8b75d592..000000000 --- a/docs/source/_static/css/larger-api-margins.css +++ /dev/null @@ -1,7 +0,0 @@ -:is(.data, .function, .class, .exception).py { - margin-top: 3em; -} - -:is(.attribute, .method).py { - margin-top: 1.8em; -} diff --git a/docs/source/_static/css/larger-headings.css b/docs/source/_static/css/larger-headings.css deleted file mode 100644 index 297ab7202..000000000 --- a/docs/source/_static/css/larger-headings.css +++ /dev/null @@ -1,9 +0,0 @@ -h1, -h2, -h3, -h4, -h5, -h6 { - margin-top: 1.5em !important; - font-weight: 900 !important; -} diff --git a/docs/source/_static/css/reactpy-view.css b/docs/source/_static/css/reactpy-view.css deleted file mode 100644 index 56df74970..000000000 --- a/docs/source/_static/css/reactpy-view.css +++ /dev/null @@ -1,43 +0,0 @@ -.interactive { - -webkit-transition: 0.1s ease-out; - -moz-transition: 0.1s ease-out; - -o-transition: 0.1s ease-out; - transition: 0.1s ease-out; -} -.widget-container { - padding: 15px; - overflow: auto; - background-color: var(--color-code-background); - min-height: 75px; -} - -.widget-container .printout { - margin-top: 20px; - border-top: solid 2px var(--color-foreground-border); - padding-top: 20px; -} - -.widget-container > div { - width: 100%; -} - -.enable-widget-button { - padding: 10px; - color: #ffffff !important; - text-transform: uppercase; - text-decoration: none; - background: #526cfe; - border: 2px solid #526cfe !important; - transition: all 0.1s ease 0s; - box-shadow: 0 5px 10px var(--color-foreground-border); -} -.enable-widget-button:hover { - color: #526cfe !important; - background: #ffffff; - transition: all 0.1s ease 0s; -} -.enable-widget-button:focus { - outline: 0 !important; - transform: scale(0.98); - transition: all 0.1s ease 0s; -} diff --git a/docs/source/_static/css/sphinx-design-overrides.css b/docs/source/_static/css/sphinx-design-overrides.css deleted file mode 100644 index 767d9d16c..000000000 --- a/docs/source/_static/css/sphinx-design-overrides.css +++ /dev/null @@ -1,14 +0,0 @@ -.sd-card-body { - display: flex; - flex-direction: column; - align-items: stretch; -} - -.sd-tab-content .highlight pre { - max-height: 700px; - overflow: auto; -} - -.sd-card-title .sd-badge { - font-size: 1em; -} diff --git a/docs/source/_static/css/widget-output-css-overrides.css b/docs/source/_static/css/widget-output-css-overrides.css deleted file mode 100644 index 7ddf1a792..000000000 --- a/docs/source/_static/css/widget-output-css-overrides.css +++ /dev/null @@ -1,8 +0,0 @@ -.widget-container h1, -.widget-container h2, -.widget-container h3, -.widget-container h4, -.widget-container h5, -.widget-container h6 { - margin: 0 !important; -} diff --git a/docs/source/_static/install-and-run-reactpy.gif b/docs/source/_static/install-and-run-reactpy.gif deleted file mode 100644 index 49d431341..000000000 Binary files a/docs/source/_static/install-and-run-reactpy.gif and /dev/null differ diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst deleted file mode 100644 index f739ce980..000000000 --- a/docs/source/about/changelog.rst +++ /dev/null @@ -1,1124 +0,0 @@ -Changelog -========= - -.. note:: - - The ReactPy team manages their short and long term plans with `GitHub Projects - <https://github.com/orgs/reactive-python/projects/1>`__. If you have questions about what - the team are working on, or have feedback on how issues should be prioritized, feel - free to :discussion-type:`open up a discussion <question>`. - -All notable changes to this project will be recorded in this document. The style of -which is based on `Keep a Changelog <https://keepachangelog.com/>`__. The versioning -scheme for the project adheres to `Semantic Versioning <https://semver.org/>`__. For -more info, see the :ref:`Contributor Guide <Creating a Changelog Entry>`. - - -.. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS -.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. If you're adding a changelog entry, be sure to read the "Creating a Changelog Entry" -.. section of the documentation before doing so for instructions on how to adhere to the -.. "Keep a Changelog" style guide (https://keepachangelog.com). - -Unreleased ----------- - -No changes. - - -v1.0.0 ------- -:octicon:`milestone` *released on 2023-03-14* - -No changes. - - -v1.0.0-a6 ---------- -:octicon:`milestone` *released on 2023-02-23* - -**Fixed** - -- :pull:`936` - remaining issues from :pull:`934` - - -v1.0.0-a5 ---------- -:octicon:`milestone` *released on 2023-02-21* - -**Fixed** - -- :pull:`934` - minor issues with camelCase rewrite CLI utility - - -v1.0.0-a4 ---------- -:octicon:`milestone` *released on 2023-02-21* - -**Changed** - -- :pull:`919` - Reverts :pull:`841` as per the conclusion in :discussion:`916`. but - preserves the ability to declare attributes with snake_case. - -**Deprecated** - -- :pull:`919` - Declaration of keys via keyword arguments in standard elements. A script - has been added to automatically convert old usages where possible. - - -v1.0.0-a3 ---------- -:octicon:`milestone` *released on 2023-02-02* - -**Fixed** - -- :pull:`908` - minor type hint issue with ``VdomDictConstructor`` - -**Removed** - -- :pull:`907` - accidental import of reactpy.testing - - -v1.0.0-a2 ---------- -:octicon:`milestone` *released on 2023-01-31* - -**Reverted** - -- :pull:`901` - reverts :pull:`886` due to :issue:`896` - -**Fixed** - -- :issue:`896` - Stale event handlers after disconnect/reconnect cycle -- :issue:`898` - Fixed CLI not registered as entry point - - -v1.0.0-a1 ---------- -:octicon:`milestone` *released on 2023-01-28* - -**Changed** - -- :pull:`841` - Revamped element constructor interface. Now instead of passing a - dictionary of attributes to element constructors, attributes are declared using - keyword arguments. For example, instead of writing: - - .. code-block:: - - html.div({"className": "some-class"}, "some", "text") - - You now should write: - - .. code-block:: - - html.div("some", "text", class_name="some-class") - - .. note:: - - All attributes are written using ``snake_case``. - - In conjunction, with these changes, ReactPy now supplies a command line utility that - makes a "best effort" attempt to automatically convert code to the new API. Usage of - this utility is as follows: - - .. code-block:: bash - - reactpy update-html-usages [PATHS] - - Where ``[PATHS]`` is any number of directories or files that should be rewritten. - - .. warning:: - - After running this utility, code comments and formatting may have been altered. It's - recommended that you run a code formatting tool like `Black - <https://github.com/psf/black>`__ and manually review and replace any comments that - may have been moved. - -**Fixed** - -- :issue:`755` - unification of component and VDOM constructor interfaces. See above. - - -v0.44.0 -------- -:octicon:`milestone` *released on 2023-01-27* - -**Deprecated** - -- :pull:`876` - ``reactpy.widgets.hotswap``. The function has no clear uses outside of some - internal applications. For this reason it has been deprecated. - -**Removed** - -- :pull:`886` - Ability to access element value from events via `event['value']` key. - Instead element value should be accessed via `event['target']['value']`. Originally - deprecated in :ref:`v0.34.0`. -- :pull:`886` - old misspelled option ``reactpy.config.REACTPY_WED_MODULES_DIR``. Originally - deprecated in :ref:`v0.36.1`. - - -v0.43.0 -------- -:octicon:`milestone` *released on 2023-01-09* - -**Deprecated** - -- :pull:`870` - ``ComponentType.should_render()``. This method was implemented based on - reading the React/Preact source code. As it turns out though it seems like it's mostly - a vestige from the fact that both these libraries still support class-based - components. The ability for components to not render also caused several bugs. - -**Fixed** - -- :issue:`846` - Nested context does no update value if outer context should not render. -- :issue:`847` - Detached model state on render of context consumer if unmounted and - context value does not change. - - -v0.42.0 -------- -:octicon:`milestone` *released on 2022-12-02* - -**Added** - -- :pull:`835` - Ability to customize the ``<head>`` element of ReactPy's built-in client. -- :pull:`835` - ``vdom_to_html`` utility function. -- :pull:`843` - Ability to subscribe to changes that are made to mutable options. -- :pull:`832` - ``del_html_head_body_transform`` to remove ``<html>``, ``<head>``, and ``<body>`` while preserving children. -- :pull:`699` - Support for form element serialization - -**Fixed** - -- :issue:`582` - ``REACTPY_DEBUG_MODE`` is now mutable and can be changed at runtime -- :pull:`832` - Fix ``html_to_vdom`` improperly removing ``<html>``, ``<head>``, and ``<body>`` nodes. - -**Removed** - -- :pull:`832` - Removed ``reactpy.html.body`` as it is currently unusable due to technological limitations, and thus not needed. -- :pull:`840` - remove ``REACTPY_FEATURE_INDEX_AS_DEFAULT_KEY`` option -- :pull:`835` - ``serve_static_files`` option from backend configuration - -**Deprecated** - -- :commit:`8f3785b` - Deprecated ``module_from_template`` - -v0.41.0 -------- -:octicon:`milestone` *released on 2022-11-01* - -**Changed** - -- :pull:`823` - The hooks ``use_location`` and ``use_scope`` are no longer - implementation specific and are now available as top-level imports. Instead of each - backend defining these hooks, backends establish a ``ConnectionContext`` with this - information. -- :pull:`824` - ReactPy's built-in backend server now expose the following routes: - - - ``/_reactpy/assets/<file-path>`` - - ``/_reactpy/stream/<path>`` - - ``/_reactpy/modules/<file-path>`` - - ``/<prefix>/<path>`` - - This should allow the browser to cache static resources. Even if your ``url_prefix`` - is ``/_reactpy``, your app should still work as expected. Though if you're using - ``reactpy-router``, ReactPy's server routes will always take priority. -- :pull:`824` - Backend implementations now strip any URL prefix in the pathname for - ``use_location``. -- :pull:`827` - ``use_state`` now returns a named tuple with ``value`` and ``set_value`` - fields. This is convenient for adding type annotations if the initial state value is - not the same as the values you might pass to the state setter. Where previously you - might have to do something like: - - .. code-block:: - - value: int | None = None - value, set_value = use_state(value) - - Now you can annotate your state using the ``State`` class: - - .. code-block:: - - state: State[int | None] = use_state(None) - - # access value and setter - state.value - state.set_value - - # can still destructure if you need to - value, set_value = state - -**Added** - -- :pull:`823` - There is a new ``use_connection`` hook which returns a ``Connection`` - object. This ``Connection`` object contains a ``location`` and ``scope``, along with - a ``carrier`` which is unique to each backend implementation. - - -v0.40.2 -------- -:octicon:`milestone` *released on 2022-09-13* - -**Changed** - -- :pull:`809` - Avoid the use of JSON patch for diffing models. - - -v0.40.1 -------- -:octicon:`milestone` *released on 2022-09-11* - -**Fixed** - -- :issue:`806` - Child models after a component fail to render - - -v0.40.0 (yanked) ----------------- -:octicon:`milestone` *released on 2022-08-13* - -**Fixed** - -- :issue:`777` - Fix edge cases where ``html_to_vdom`` can fail to convert HTML -- :issue:`789` - Conditionally rendered components cannot use contexts -- :issue:`773` - Use strict equality check for text, numeric, and binary types in hooks -- :issue:`801` - Accidental mutation of old model causes invalid JSON Patch - -**Changed** - -- :pull:`123` - set default timeout on playwright page for testing -- :pull:`787` - Track contexts in hooks as state -- :pull:`787` - remove non-standard ``name`` argument from ``create_context`` - -**Added** - -- :pull:`123` - ``asgiref`` as a dependency -- :pull:`795` - ``lxml`` as a dependency - - -v0.39.0 -------- -:octicon:`milestone` *released on 2022-06-20* - -**Fixed** - -- :pull:`763` - ``No module named 'reactpy.server'`` from ``reactpy.run`` -- :pull:`749` - Setting appropriate MIME type for web modules in `sanic` server implementation - -**Changed** - -- :pull:`763` - renamed various: - - - ``reactpy.testing.server -> reactpy.testing.backend`` - - ``ServerFixture -> BackendFixture`` - - ``DisplayFixture.server -> DisplayFixture.backend`` - -- :pull:`765` - ``exports_default`` parameter is removed from ``module_from_template``. - -**Added** - -- :pull:`765` - ability to specify versions with module templates (e.g. - ``module_from_template("react@^17.0.0", ...)``). - - -v0.38.1 -------- -:octicon:`milestone` *released on 2022-04-15* - -**Fixed** - -- `reactive-python/reactpy-jupyter#22 <https://github.com/reactive-python/reactpy-jupyter/issues/22>`__ - - a missing file extension was causing a problem with WebPack. - - -v0.38.0 -------- -:octicon:`milestone` *released on 2022-04-15* - -No changes. - - -v0.38.0-a4 ----------- -:octicon:`milestone` *released on 2022-04-15* - -**Added** - -- :pull:`733` - ``use_debug_value`` hook - -**Changed** - -- :pull:`733` - renamed ``assert_reactpy_logged`` testing util to ``assert_reactpy_did_log`` - - -v0.38.0-a3 ----------- -:octicon:`milestone` *released on 2022-04-15* - -**Changed** - -- :pull:`730` - Layout context management is not async - - -v0.38.0-a2 ----------- -:octicon:`milestone` *released on 2022-04-14* - -**Added** - -- :pull:`721` - Implement ``use_location()`` hook. Navigating to any route below the - root of the application will be reflected in the ``location.pathname``. This operates - in concert with how ReactPy's configured routes have changed. This will ultimately work - towards resolving :issue:`569`. - -**Changed** - -- :pull:`721` - The routes ReactPy configures on apps have changed - - .. code-block:: text - - prefix/_api/modules/* web modules - prefix/_api/stream websocket endpoint - prefix/* client react app - - This means that ReactPy's client app is available at any route below the configured - ``url_prefix`` besides ``prefix/_api``. The ``_api`` route will likely remain a route - which is reserved by ReactPy. The route navigated to below the ``prefix`` will be shown - in ``use_location``. - -- :pull:`721` - ReactPy's client now uses Preact instead of React - -- :pull:`726` - Renamed ``reactpy.server`` to ``reactpy.backend``. Other references to "server - implementations" have been renamed to "backend implementations" throughout the - documentation and code. - -**Removed** - -- :pull:`721` - ``redirect_root`` server option - - -v0.38.0-a1 ----------- -:octicon:`milestone` *released on 2022-03-27* - -**Changed** - -- :pull:`703` - How ReactPy integrates with servers. ``reactpy.run`` no longer accepts an app - instance to discourage use outside of testing. ReactPy's server implementations now - provide ``configure()`` functions instead. ``reactpy.testing`` has been completely - reworked in order to support async web drivers -- :pull:`703` - ``PerClientStateServer`` has been functionally replaced by ``configure`` - -**Added** - -- :issue:`669` - Access to underlying server requests via contexts - -**Removed** - -- :issue:`669` - Removed ``reactpy.widgets.multiview`` since basic routing view ``use_scope`` is - now possible as well as all ``SharedClientStateServer`` implementations. - -**Fixed** - -- :issue:`591` - ReactPy's test suite no longer uses sync web drivers -- :issue:`678` - Updated Sanic requirement to ``>=21`` -- :issue:`657` - How we advertise ``reactpy.run`` - - -v0.37.2 -------- -:octicon:`milestone` *released on 2022-03-27* - -**Changed** - -- :pull:`701` - The name of ``proto`` modules to ``types`` and added a top level - ``reactpy.types`` module - -**Fixed** - -- :pull:`716` - A typo caused ReactPy to use the insecure ``ws`` web-socket protocol on - pages loaded with ``https`` instead of the secure ``wss`` protocol - - -v0.37.1 -------- -:octicon:`milestone` *released on 2022-03-05* - -No changes. - - -v0.37.1-a2 ----------- -:octicon:`milestone` *released on 2022-03-02* - -**Fixed:** - -- :issue:`684` - Revert :pull:`694` and by making ``value`` uncontrolled client-side - - -v0.37.1-a1 ----------- -:octicon:`milestone` *released on 2022-02-28* - -**Fixed:** - -- :issue:`684` - ``onChange`` event for inputs missing key strokes - - -v0.37.0 -------- -:octicon:`milestone` *released on 2022-02-27* - -**Added:** - -- :issue:`682` - Support for keys in HTML fragments -- :pull:`585` - Use Context Hook - -**Fixed:** - -- :issue:`690` - React warning about set state in unmounted component -- :pull:`688` - Missing reset of schedule_render_later flag - ----- - -Releases below do not use the "Keep a Changelog" style guidelines. - ----- - -v0.36.3 -------- -:octicon:`milestone` *released on 2022-02-18* - -Misc bug fixes along with a minor improvement that allows components to return ``None`` -to render nothing. - -**Closed Issues** - -- All child states wiped upon any child key change - :issue:`652` -- Allow NoneType returns within components - :issue:`538` - -**Merged Pull Requests** - -- fix #652 - :pull:`672` -- Fix 663 - :pull:`667` - - -v0.36.2 -------- -:octicon:`milestone` *released on 2022-02-02* - -Hot fix for newly introduced ``DeprecatedOption``: - -- :commit:`c146dfb264cbc3d2256a62efdfe9ccf62c795b01` - - -v0.36.1 -------- -:octicon:`milestone` *released on 2022-02-02* - -Includes bug fixes and renames the configuration option ``REACTPY_WED_MODULES_DIR`` to -``REACTPY_WEB_MODULES_DIR`` with a corresponding deprecation warning. - -**Closed Issues** - -- Fix Key Error When Cleaning Up Event Handlers - :issue:`640` -- Update Script Tag Behavior - :issue:`628` - -**Merged Pull Requests** - -- mark old state as None if unmounting - :pull:`641` -- rename REACTPY_WED_MODULES_DIR to REACTPY_WEB_MODULES_DIR - :pull:`638` - - -v0.36.0 -------- -:octicon:`milestone` *released on 2022-01-30* - -This release includes an important fix for errors produced after :pull:`623` was merged. -In addition there is not a new ``http.script`` element which can behave similarly to a -standard HTML ``<script>`` or, if no attributes are given, operate similarly to an -effect. If no attributes are given, and when the script evaluates to a function, that -function will be called the first time it is mounted and any time the content of the -script is subsequently changed. If the function then returns another function, that -returned function will be called when the script is removed from the view, or just -before the content of the script changes. - -**Closed Issues** - -- State mismatch during component update - :issue:`629` -- Implement a script tag - :issue:`544` - -**Pull Requests** - -- make scripts behave more like normal html script element - :pull:`632` -- Fix state mismatch during component update - :pull:`631` -- implement script element - :pull:`617` - - -v0.35.4 -------- -:octicon:`milestone` *released on 2022-01-27* - -Keys for elements at the root of a component were not being tracked. Thus key changes -for elements at the root did not trigger unmounts. - -**Closed Issues** - -- Change Key of Parent Element Does Not Unmount Children - :issue:`622` - -**Pull Requests** - -- fix issue with key-based identity - :pull:`623` - - -v0.35.3 -------- -:octicon:`milestone` *released on 2022-01-27* - -As part of :pull:`614`, elements which changed type were not deeply unmounted. This -behavior is probably undesirable though since the state for children of the element -in question would persist (probably unexpectedly). - -**Pull Requests** - -- Always deeply unmount - :pull:`620` - - -v0.35.2 -------- -:octicon:`milestone` *released on 2022-01-26* - -This release includes several bug fixes. The most significant of which is the ability to -change the type of an element in the try (i.e. to and from being a component) without -getting an error. Originally the errors were introduced because it was though changing -element type would not be desirable. This was not the case though - swapping types -turns out to be quite common and useful. - -**Closed Issues** - -- Allow Children with the Same Key to Vary in Type - :issue:`613` -- Client Always Looks for Server at "/" - :issue:`611` -- Web modules get double file extensions with v0.35.x - :issue:`605` - -**Pull Requests** - -- allow elements with the same key to change type - :pull:`614` -- make connection to websocket relative path - :pull:`612` -- fix double file extension - :pull:`606` - - -v0.35.1 -------- -:octicon:`milestone` *released on 2022-01-18* - -Re-add accidentally deleted ``py.typed`` file to distribution. See `PEP-561 -<https://www.python.org/dev/peps/pep-0561/#packaging-type-information>`__ for info on -this marker file. - - -v0.35.0 -------- -:octicon:`milestone` *released on 2022-01-18* - -The highlight of this release is that the default :ref:`"key" <Organizing Items With -Keys>` of all elements will be their index amongst their neighbors. Previously this -behavior could be engaged by setting ``REACTPY_FEATURE_INDEX_AS_DEFAULT_KEY=1`` when -running ReactPy. In this release though, you will need to explicitly turn off this feature -(i.e. ``=0``) to return to the old behavior. With this change, some may notice -additional error logs which warn that: - -.. code-block:: text - - Key not specified for child in list ... - -This is saying is that an element or component which was created in a list does not have -a unique ``key``. For more information on how to mitigate this warning refer to the docs -on :ref:`Organizing Items With Keys`. - -**Closed Issues** - -- Support Starlette Server - :issue:`588` -- Fix unhandled case in module_from_template - :issue:`584` -- Hide "Children" within REACTPY_DEBUG_MODE key warnings - :issue:`562` -- Bug in Element Key Identity - :issue:`556` -- Add iFrame to reactpy.html - :issue:`542` -- Create a use_linked_inputs widget instead of Input - :issue:`475` -- React warning from module_from_template - :issue:`440` -- Use Index as Default Key - :issue:`351` - -**Pull Requests** - -- add ``use_linked_inputs`` - :pull:`593` -- add starlette server implementation - :pull:`590` -- Log on web module replacement instead of error - :pull:`586` -- Make Index Default Key - :pull:`579` -- reduce log spam from missing keys in children - :pull:`564` -- fix bug in element key identity - :pull:`563` -- add more standard html elements - :pull:`554` - - -v0.34.0 -------- -:octicon:`milestone` *released on 2021-12-16* - -This release contains a variety of minor fixes and improvements which came out of -rewriting the documentation. The most significant of these changes is the remove of -target element attributes from the top-level of event data dictionaries. For example, -instead of being able to find the value of an input at ``event["value"]`` it will -instead be found at ``event["target"]["value"]``. For a short period we will issue a -:class:`DeprecationWarning` when target attributes are requested at the top-level of the -event dictionary. As part of this change we also add ``event["currentTarget"]`` and -``event["relatedTarget"]`` keys to the event dictionary as well as a -``event[some_target]["boundingClientRect"]`` where ``some_target`` may be ``"target"``, -``"currentTarget"`` or ``"relatedTarget"``. - -**Closed Issues** - -- Move target attributes to ``event['target']`` - :issue:`548` - -**Pull Requests** - -- Correctly Handle Target Event Data - :pull:`550` -- Clean up WS console logging - :pull:`522` -- automatically infer closure arguments - :pull:`520` -- Documentation Rewrite - :pull:`519` -- add option to replace existing when creating a module - :pull:`516` - - -v0.33.3 -------- -:octicon:`milestone` *released on 2021-10-08* - -Contains a small number of bug fixes and improvements. The most significant change is -the addition of a warning stating that `REACTPY_FEATURE_INDEX_AS_DEFAULT_KEY=1` will become -the default in a future release. Beyond that, a lesser improvement makes it possible to -use the default export from a Javascript module when calling `module_from_template` by -specifying `exports_default=True` as a parameter. A - -**Closed Issues** - -- Memory leak in SharedClientStateServer - :issue:`511` -- Cannot use default export in react template - :issue:`502` -- Add warning that element index will be used as the default key in a future release - :issue:`428` - -**Pull Requests** - -- warn that REACTPY_FEATURE_INDEX_AS_DEFAULT_KEY=1 will be the default - :pull:`515` -- clean up patch queues after exit - :pull:`514` -- Remove Reconnecting WS alert - :pull:`513` -- Fix 502 - :pull:`503` - - -v0.33.2 -------- -:octicon:`milestone` *released on 2021-09-05* - -A release to fix a memory leak caused by event handlers that were not being removed -when components updated. - -**Closed Issues** - -- Non-root component event handlers cause memory leaks - :issue:`510` - - -v0.33.1 -------- -:octicon:`milestone` *released on 2021-09-02* - -A hot fix for a regression introduced in ``0.33.0`` where the root element of the layout -could not be updated. See :issue:`498` for more info. A regression test for this will -be introduced in a future release. - -**Pull Requests** - -- Fix 498 pt1 - :pull:`501` - - -v0.33.0 -------- -:octicon:`milestone` *released on 2021-09-02* - -The most significant fix in this release is for a regression which manifested in -:issue:`480`, :issue:`489`, and :issue:`451` which resulted from an issue in the way -JSON patches were being applied client-side. This was ultimately resolved by -:pull:`490`. While it's difficult to test this without a more thorough Javascript -suite, we added a test that should hopefully catch this in the future by proxy. - -The most important breaking change, is yet another which modifies the Custom Javascript -Component interface. We now add a ``create()`` function to the ``bind()`` interface that -allows ReactPy's client to recursively create components from that (and only that) import -source. Prior to this, the interface was given unrendered models for child elements. The -imported module was then responsible for rendering them. This placed a large burden on -the author to understand how to handle these unrendered child models. In addition, in -the React template used by ``module_from_template`` we needed to import a version of -``@reactpy/client`` from the CDN - this had already caused some issues where the -template required a version of ``@reactpy/client`` in the which had not been released -yet. - -**Closed Issues** - -- Client-side error in mount-01d35dc3.js - :issue:`489` -- Style Cannot Be Updated - :issue:`480` -- Displaying error messages in the client via `__error__` tag can leak secrets - :issue:`454` -- Examples broken in docs - :issue:`451` -- Rework docs landing page - :issue:`446` -- eventHandlers should be a mapping of generic callables - :issue:`423` -- Allow customization of built-in ReactPy client - :issue:`253` - -**Pull Requests** - -- move VdomDict and VdomJson to proto - :pull:`492` -- only send error info in debug mode - :pull:`491` -- correctly apply client-side JSON patch - :pull:`490` -- add script to set version of all packages in ReactPy - :pull:`483` -- Pass import source to bind - :pull:`482` -- Do not mutate client-side model - :pull:`481` -- assume import source children come from same source - :pull:`479` -- make an EventHandlerType protocol - :pull:`476` -- Update issue form - :pull:`471` - - -v0.32.0 -------- -:octicon:`milestone` *released on 2021-08-20* - -In addition to a variety of bug fixes and other minor improvements, there's a breaking -change to the custom component interface - instead of exporting multiple functions that -render custom components, we simply expect a single ``bind()`` function. -binding function then must return an object with a ``render()`` and ``unmount()`` -function. This change was made in order to better support the rendering of child models. -See :ref:`Custom JavaScript Components` for details on the new interface. - -**Closed Issues** - -- Docs broken on Firefox - :issue:`469` -- URL resolution for web modules does not consider urls starting with / - :issue:`460` -- Query params in package name for module_from_template not stripped - :issue:`455` -- Make docs section margins larger - :issue:`450` -- Search broken in docs - :issue:`443` -- Move src/reactpy/client out of Python package - :issue:`429` -- Use composition instead of classes async with Layout and LifeCycleHook - :issue:`412` -- Remove Python language extension - :issue:`282` -- Add keys to models so React doesn't complain of child arrays requiring them - - :issue:`255` -- Fix binder link in docs - :issue:`231` - -**Pull Requests** - -- Update issue form - :pull:`471` -- improve heading legibility - :pull:`470` -- fix search in docs by upgrading sphinx - :pull:`462` -- rework custom component interface with bind() func - :pull:`458` -- parse package as url path in module_from_template - :pull:`456` -- add file extensions to import - :pull:`439` -- fix key warnings - :pull:`438` -- fix #429 - move client JS to top of src/ dir - :pull:`430` - - -v0.31.0 -------- -:octicon:`milestone` *released on 2021-07-14* - -The :class:`~reactpy.core.layout.Layout` is now a prototype, and ``Layout.update`` is no -longer a public API. This is combined with a much more significant refactor of the -underlying rendering logic. - -The biggest issue that has been resolved relates to the relationship between -:class:`~reactpy.core.hooks.LifeCycleHook` and ``Layout``. Previously, the -``LifeCycleHook`` accepted a layout instance in its constructor and called -``Layout.update``. Additionally, the ``Layout`` would manipulate the -``LifeCycleHook.component`` attribute whenever the component instance changed after a -render. The former behavior leads to a non-linear code path that's a touch to follow. -The latter behavior is the most egregious design issue since there's absolutely no local -indication that the component instance can be swapped out (not even a comment). - -The new refactor no longer binds component or layout instances to a ``LifeCycleHook``. -Instead, the hook simply receives an un-parametrized callback that can be triggered to -schedule a render. While some error logs lose clarity (since we can't say what component -caused them). This change precludes a need for the layout to ever mutate the hook. - -To accommodate this change, the internal representation of the layout's state had to -change. Previously, a class-based approach was take, where methods of the state-holding -classes were meant to handle all use cases. Now we rely much more heavily on very simple -(and mostly static) data structures that have purpose built constructor functions that -much more narrowly address each use case. - -After these refactors, ``ComponentTypes`` no longer needs a unique ``id`` attribute. -Instead, a unique ID is generated internally which is associated with the -``LifeCycleState``, not component instances since they are inherently transient. - -**Pull Requests** - -- fix #419 and #412 - :pull:`422` - - -v0.30.1 -------- -:octicon:`milestone` *released on 2021-07-13* - -Removes the usage of the :func:`id` function for generating unique ideas because there -were situations where the IDs bound to the lifetime of an object are problematic. Also -adds a warning :class:`Deprecation` warning to render functions that include the -parameter ``key``. It's been decided that allowing ``key`` to be used in this way can -lead to confusing bugs. - -**Pull Requests** - -- warn if key is param of component render function - :pull:`421` -- fix :issue:`417` and :issue:`413` - :pull:`418` -- add changelog entry for :ref:`v0.30.0` - :pull:`415` - - -v0.30.0 -------- -:octicon:`milestone` *released on 2021-06-28* - -With recent changes to the custom component interface, it's now possible to remove all -runtime reliance on NPM. Doing so has many virtuous knock-on effects: - -1. Removal of large chunks of code -2. Greatly simplifies how users dynamically experiment with React component libraries, - because their usage no longer requires a build step. Instead they can be loaded in - the browser from a CDN that distributes ESM modules. -3. The built-in client code needs to make fewer assumption about where static resources - are located, and as a result, it's also easier to coordinate the server and client - code. -4. Alternate client implementations benefit from this simplicity. Now, it's possible to - install @reactpy/client normally and write a ``loadImportSource()`` function that - looks for route serving the contents of `REACTPY_WEB_MODULES_DIR.` - -This change includes large breaking changes: - -- The CLI is being removed as it won't be needed any longer -- The `reactpy.client` is being removed in favor of a stripped down ``reactpy.web`` module -- The `REACTPY_CLIENT_BUILD_DIR` config option will no longer exist and a new - ``REACTPY_WEB_MODULES_DIR`` which only contains dynamically linked web modules. While - this new directory's location is configurable, it is meant to be transient and should - not be re-used across sessions. - -The new ``reactpy.web`` module takes a simpler approach to constructing import sources and -expands upon the logic for resolving imports by allowing exports from URLs to be -discovered too. Now, that ReactPy isn't using NPM to dynamically install component -libraries ``reactpy.web`` instead creates JS modules from template files and links them -into ``REACTPY_WEB_MODULES_DIR``. These templates ultimately direct the browser to load the -desired library from a CDN. - -**Pull Requests** - -- Add changelog entry for 0.30.0 - :pull:`415` -- Fix typo in index.rst - :pull:`411` -- Add event handlers docs - :pull:`410` -- Misc doc improvements - :pull:`409` -- Port first ReactPy article to docs - :pull:`408` -- Test build in CI - :pull:`404` -- Remove all runtime reliance on NPM - :pull:`398` - - -v0.29.0 -------- -:octicon:`milestone` *released on 2021-06-20* - -Contains breaking changes, the most significant of which are: - -- Moves the runtime client build directory to a "user data" directory rather a directory - where ReactPy's code was installed. This has the advantage of not requiring write - permissions to rebuild the client if ReactPy was installed globally rather than in a - virtual environment. -- The custom JS component interface has been reworked to expose an API similar to - the ``createElement``, ``render``, ``unmountComponentAtNode`` functions from React. - -**Issues Fixed:** - -- :issue:`375` -- :issue:`394` -- :issue:`401` - -**Highlighted Commits:** - -- add try/except around event handling - :commit:`f2bf589` -- do not call find_builtin_server_type at import time - :commit:`e29745e` -- import default from react/reactDOM/fast-json-patch - :commit:`74c8a34` -- no named exports for react/reactDOM - :commit:`f13bf35` -- debug logs for runtime build dir create/update - :commit:`af94f4e` -- put runtime build in user data dir - :commit:`0af69d2` -- change shared to update_on_change - :commit:`6c09a86` -- rework js module interface + fix docs - :commit:`699cc66` -- correctly serialize File object - :commit:`a2398dc` - - -v0.28.0 -------- -:octicon:`milestone` *released on 2021-06-01* - -Includes a wide variety of improvements: - -- support ``currentTime`` attr of audio/video elements -- support for the ``files`` attribute from the target of input elements -- model children are passed to the Javascript ``mount()`` function -- began to add tests to client-side javascript -- add a ``mountLayoutWithWebSocket`` function to ``@reactpy/client`` - -and breaking changes, the most significant of which are: - -- Refactor existing server implementations as functions adhering to a protocol. This - greatly simplified much of the code responsible for setting up servers and avoids - the use of inheritance. -- Switch to a monorepo-style structure for Javascript enabling a greater separation of - concerns and common workspace scripts in ``package.json``. -- Use a ``loadImportSource()`` function instead of trying to infer the path to dynamic - modules which was brittle and inflexible. Allowing the specific client implementation - to discover where "import sources" are located means ``@reactpy/client`` doesn't - need to try and devise a solution that will work for all cases. The fallout from this - change is the addition of `importSource.sourceType` which, for the moment can either - be ``"NAME"`` or ``"URL"`` where the former indicates the client is expected to know - where to find a module of that name, and the latter should (usually) be passed on to - ``import()`` - - -**Issues Fixed:** - -- :issue:`324` (partially resolved) -- :issue:`375` - -**Highlighted Commits:** - -- xfail due to bug in Python - :commit:`fee49a7` -- add importSource sourceType field - :commit:`795bf94` -- refactor client to use loadImportSource param - :commit:`bb5e3f3` -- turn app into a package - :commit:`b282fc2` -- add debug logs - :commit:`4b4f9b7` -- add basic docs about JS test suite - :commit:`9ecfde5` -- only use nox for python tests - :commit:`5056b7b` -- test event serialization - :commit:`05fd86c` -- serialize files attribute of file input element - :commit:`f0d00b7` -- rename hasMount to exportsMount - :commit:`d55a28f` -- refactor flask - :commit:`94681b6` -- refactor tornado + misc fixes to sanic/fastapi - :commit:`16c9209` -- refactor fastapi using server protocol - :commit:`0cc03ba` -- refactor sanic server - :commit:`43d4b4f` -- use server protocol instead of inheritance - :commit:`abe0fde` -- support currentTime attr of audio/video elements - :commit:`975b54a` -- pass children as props to mount() - :commit:`9494bc0` - - -v0.27.0 -------- -:octicon:`milestone` *released on 2021-05-14* - -Introduces changes to the interface for custom Javascript components. This now allows -JS modules to export a ``mount(element, component, props)`` function which can be used -to bind new elements to the DOM instead of using the application's own React instance -and specifying React as a peer dependency. This avoids a wide variety of potential -issues with implementing custom components and opens up the possibility for a wider -variety of component implementations. - -**Highlighted Commits:** - -- modules with mount func should not have children - :commit:`94d006c` -- limit to flask<2.0 - :commit:`e7c11d0` -- federate modules with mount function - :commit:`bf63a62` - - -v0.26.0 -------- -:octicon:`milestone` *released on 2021-05-07* - -A collection of minor fixes and changes that, as a whole, add up to something requiring -a minor release. The most significant addition is a fix for situations where a -``Layout`` can raise an error when a component whose state has been delete is rendered. -This occurs when element has been unmounted, but a latent event tells the layout it -should be updated. For example, when a user clicks a button rapidly, and the resulting -update deletes the original button. - -**Highlighted Commits:** - -- only one attr dict in vdom constructor - :commit:`555086a` -- remove Option setter/getter with current property - :commit:`2627f79` -- add cli command to show options - :commit:`c9e6869` -- check component has model state before render - :commit:`6a50d56` -- rename daemon to run_in_thread + misc - :commit:`417b687` - - -v0.25.0 -------- -:octicon:`milestone` *released on 2021-04-30* - -Completely refactors layout dispatcher by switching from a class-based approach to one -that leverages pure functions. While the logic itself isn't any simpler, it was easier -to implement, and now hopefully understand, correctly. This conversion was motivated by -several bugs that had cropped up related to improper usage of ``anyio``. - -**Issues Fixed:** - -- :issue:`330` -- :issue:`298` - -**Highlighted Commits:** - -- improve docs + simplify multi-view - :commit:`4129b60` -- require anyio>=3.0 - :commit:`24aed28` -- refactor dispatchers - :commit:`ce8e060` - - -v0.24.0 -------- -:octicon:`milestone` *released on 2021-04-18* - -This release contains an update that allows components and elements to have "identity". -That is, their state can be preserved across updates. Before this point, only the state -for the component at the root of an update was preserved. Now though, the state for any -component and element with a ``key`` that is unique amongst its siblings, will be -preserved so long as this is also true for parent elements/components within the scope -of the current update. Thus, only when the key of the element or component changes will -its state do the same. - -In a future update, the default key for all elements and components will be its index -with respect to its siblings in the layout. The -:attr:`~reactpy.config.REACTPY_FEATURE_INDEX_AS_DEFAULT_KEY` feature flag has been introduced -to allow users to enable this behavior early. - -**Highlighted Commits:** - -- add feature flag for default key behavior - :commit:`42ee01c` -- use unique object instead of index as default key - :commit:`5727ab4` -- make HookCatcher/StaticEventHandlers testing utils - :commit:`1abfd76` -- add element and component identity - :commit:`5548f02` -- minor doc updates - :commit:`e5511d9` -- add tests for callback identity preservation with keys - :commit:`72e03ec` -- add 'key' to VDOM spec - :commit:`c3236fe` -- Rename validate_serialized_vdom to validate_vdom_json - :commit:`d04faf9` -- EventHandler should not serialize itself - :commit:`f7a59f2` -- fix docs typos - :commit:`42b2e20` -- fixes: #331 - add roadmap to docs - :commit:`4226c12` - - -v0.23.1 -------- -:octicon:`milestone` *released on 2021-04-02* - -**Highlighted Commits:** - -- fix non-deterministic return order in install() - :commit:`494d5c2` - - -v0.23.0 -------- -:octicon:`milestone` *released on 2021-04-01* - -**Highlighted Commits:** - -- add changelog to docs - :commit:`9cbfe94` -- automatically reconnect to server - :commit:`3477e2b` -- allow no reconnect in client - :commit:`ef263c2` -- cleaner way to specify import sources - :commit:`ea19a07` -- add the reactpy-react-client back into the main repo - :commit:`5dcc3bb` -- implement fastapi render server - :commit:`94e0620` -- improve docstring for REACTPY_CLIENT_BUILD_DIR - :commit:`962d885` -- cli improvements - :commit:`788fd86` -- rename SERIALIZED_VDOM_JSON_SCHEMA to VDOM_JSON_SCHEMA - :commit:`74ad578` -- better logging for modules - :commit:`39565b9` -- move client utils into private module - :commit:`f825e96` -- redirect BUILD_DIR imports to REACTPY_CLIENT_BUILD_DIR option - :commit:`53fb23b` -- upgrade snowpack - :commit:`5697a2d` -- better logs for reactpy.run + flask server - :commit:`2b34e3d` -- move package to src dir - :commit:`066c9c5` -- reactpy restore uses backup - :commit:`773f78e` diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst deleted file mode 100644 index b44be9b7e..000000000 --- a/docs/source/about/contributor-guide.rst +++ /dev/null @@ -1,331 +0,0 @@ -Contributor Guide -================= - -.. note:: - - The - `Code of Conduct <https://github.com/reactive-python/reactpy/blob/main/CODE_OF_CONDUCT.md>`__ - applies in all community spaces. If you are not familiar with our Code of Conduct - policy, take a minute to read it before making your first contribution. - -The ReactPy team welcomes contributions and contributors of all kinds - whether they come -as code changes, participation in the discussions, opening issues and pointing out bugs, -or simply sharing your work with your colleagues and friends. We're excited to see how -you can help move this project and community forward! - - -.. _everyone can contribute: - -Everyone Can Contribute! ------------------------- - -Trust us, there's so many ways to support the project. We're always looking for people -who can: - -- Improve our documentation -- Teach and tell others about ReactPy -- Share ideas for new features -- Report bugs -- Participate in general discussions - -Still aren't sure what you have to offer? Just :discussion-type:`ask us <question>` and -we'll help you make your first contribution. - - -Making a Pull Request ---------------------- - -To make your first code contribution to ReactPy, you'll need to install Git_ (or -`Git Bash`_ on Windows). Thankfully there are many helpful -`tutorials <https://github.com/firstcontributions/first-contributions/blob/master/README.md>`__ -about how to get started. To make a change to ReactPy you'll do the following: - -`Fork ReactPy <https://docs.github.com/en/github/getting-started-with-github/fork-a-repo>`__: - Go to `this URL <https://github.com/reactive-python/reactpy>`__ and click the "Fork" button. - -`Clone your fork <https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository>`__: - You use a ``git clone`` command to copy the code from GitHub to your computer. - -`Create a new branch <https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging>`__: - You'll ``git checkout -b your-first-branch`` to create a new space to start your work. - -:ref:`Prepare your Development Environment <Development Environment>`: - We explain in more detail below how to install all ReactPy's dependencies. - -`Push your changes <https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository>`__: - Once you've made changes to ReactPy, you'll ``git push`` them to your fork. - -:ref:`Create a changelog entry <Creating a changelog entry>`: - Record your changes in the :ref:`changelog` so we can publicize them in the next release. - -`Create a Pull Request <https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request>`__: - We'll review your changes, run some :ref:`tests <Running The Tests>` and - :ref:`equality checks <Code Quality Checks>` and, with any luck, accept your request. - At that point your contribution will be merged into the main codebase! - - -Development Environment ------------------------ - -.. note:: - - If you have any questions during set up or development post on our - :discussion-type:`discussion board <question>` and we'll answer them. - -In order to develop ReactPy locally you'll first need to install the following: - -.. list-table:: - :header-rows: 1 - - * - What to Install - - How to Install - - * - Python >= 3.9 - - https://realpython.com/installing-python/ - - * - Hatch - - https://hatch.pypa.io/latest/install/ - - * - Poetry - - https://python-poetry.org/docs/#installation - - * - Git - - https://git-scm.com/book/en/v2/Getting-Started-Installing-Git - - * - NodeJS >= 14 - - https://nodejs.org/en/download/package-manager/ - - * - NPM >= 7.13 - - https://docs.npmjs.com/try-the-latest-stable-version-of-npm - - * - Docker (optional) - - https://docs.docker.com/get-docker/ - -.. note:: - - NodeJS distributes a version of NPM, but you'll want to get the latest - -Once done, you can clone a local copy of this repository: - -.. code-block:: bash - - git clone https://github.com/reactive-python/reactpy.git - cd reactpy - -Then, you should be able to activate your development environment with: - -.. code-block:: bash - - hatch shell - - -Running The Tests ------------------ - -Tests exist for both Python and Javascript. These can be run with the following: - -.. code-block:: bash - - hatch run test-py - hatch run test-js - -If you want to run tests for individual packages you'll need to ``cd`` into the -package directory and run the tests from there. For example, to run the tests just for -the ``reactpy`` package you'd do: - -.. code-block:: bash - - cd src/py/reactpy - hatch run test --headed # run the tests in a browser window - -For Javascript, you'd do: - -.. code-block:: bash - - cd src/js/packages/event-to-object - npm run check:tests - - -Code Quality Checks -------------------- - -Several tools are run on the codebase to help validate its quality. For the most part, -if you set up your :ref:`Development Environment` with pre-commit_ to check your work -before you commit it, then you'll be notified when changes need to be made or, in the -best case, changes will be made automatically for you. - -The following are currently being used: - -- MyPy_ - a static type checker -- Black_ - an opinionated code formatter -- Flake8_ - a style guide enforcement tool -- Ruff_ - An extremely fast Python linter, written in Rust. -- Prettier_ - a tool for automatically formatting various file types -- EsLint_ - A Javascript linter - -The most strict measure of quality enforced on the codebase is 100% test coverage in -Python files. This means that every line of coded added to ReactPy requires a test case -that exercises it. This doesn't prevent all bugs, but it should ensure that we catch the -most common ones. - -If you need help understanding why code you've submitted does not pass these checks, -then be sure to ask, either in the :discussion-type:`Community Forum <question>` or in -your :ref:`Pull Request <Making a Pull Request>`. - -.. note:: - - You can manually run ``hatch run lint --fix`` to auto format your code without - having to do so via ``pre-commit``. However, many IDEs have ways to automatically - format upon saving a file (e.g. - `VSCode <https://code.visualstudio.com/docs/python/editing#_formatting>`__) - - -Building The Documentation --------------------------- - -To build and display the documentation locally run: - -.. code-block:: bash - - hatch run docs - -This will compile the documentation from its source files into HTML, start a web server, -and open a browser to display the now generated documentation. Whenever you change any -source files the web server will automatically rebuild the documentation and refresh the -page. Under the hood this is using -`sphinx-autobuild <https://github.com/executablebooks/sphinx-autobuild>`__. - -To run some of the examples in the documentation as if they were tests run: - -.. code-block:: bash - - hatch run test-docs - -Building the documentation as it's deployed in production requires Docker_. Once you've -installed Docker, you can run: - -.. code-block:: bash - - hatch run docs --docker - -Where you can then navigate to http://localhost:5000.. - - -Creating a Changelog Entry --------------------------- - -As part of your pull request, you'll want to edit the `Changelog -<https://github.com/reactive-python/reactpy/blob/main/docs/source/about/changelog.rst>`__ by -adding an entry describing what you've changed or improved. You should write an entry in -the style of `Keep a Changelog <https://keepachangelog.com/>`__ that falls under one of -the following categories, and add it to the :ref:`Unreleased` section of the changelog: - -- **Added** - for new features. -- **Changed** - for changes in existing functionality. -- **Deprecated** - for soon-to-be removed features. -- **Removed** - for now removed features. -- **Fixed** - for any bug fixes. -- **Documented** - for improvements to this documentation. -- **Security** - in case of vulnerabilities. - -If one of the sections doesn't exist, add it. If it does already, add a bullet point -under the relevant section. Your description should begin with a reference to the -relevant issue or pull request number. Here's a short example of what an unreleased -changelog entry might look like: - -.. code-block:: rst - - Unreleased - ---------- - - **Added** - - - :pull:`123` - A really cool new feature - - **Changed** - - - :pull:`456` - The behavior of some existing feature - - **Fixed** - - - :issue:`789` - Some really bad bug - -.. hint:: - - ``:issue:`` and ``:pull:`` refer to issue and pull request ticket numbers. - - -Release Process ---------------- - -Creating a release for ReactPy involves two steps: - -1. Tagging a version -2. Publishing a release - -To **tag a version** you'll run the following command: - -.. code-block:: bash - - nox -s tag -- <the-new-version> - -Which will update the version for: - -- Python packages -- Javascript packages -- The changelog - -You'll be then prompted to confirm the auto-generated updates before those changes will -be staged, committed, and pushed along with a new tag matching ``<the-new-version>`` -which was specified earlier. - -Lastly, to **publish a release** `create one in GitHub -<https://docs.github.com/en/github/administering-a-repository/releasing-projects-on-github/managing-releases-in-a-repository>`__. -Because we pushed a tag using the command above, there should already be a saved tag you -can target when authoring the release. The release needs a title and description. The -title should simply be the version (same as the tag), and the description should simply -use GitHub's "Auto-generated release notes". - - -Other Core Repositories ------------------------ - -ReactPy depends on, or is used by several other core projects. For documentation on them -you should refer to their respective documentation in the links below: - -- `reactpy-js-component-template - <https://github.com/reactive-python/reactpy-js-component-template>`__ - Template repo - for making :ref:`Custom Javascript Components`. -- `reactpy-flake8 <https://github.com/reactive-python/reactpy-flake8>`__ - Enforces the - :ref:`Rules of Hooks` -- `reactpy-jupyter <https://github.com/reactive-python/reactpy-jupyter>`__ - ReactPy integration for - Jupyter -- `reactpy-dash <https://github.com/reactive-python/reactpy-dash>`__ - ReactPy integration for Plotly - Dash -- `django-reactpy <https://github.com/reactive-python/django-reactpy>`__ - ReactPy integration for - Django - -.. Links -.. ===== - -.. _Google Chrome: https://www.google.com/chrome/ -.. _Docker: https://docs.docker.com/get-docker/ -.. _Git: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git -.. _Git Bash: https://gitforwindows.org/ -.. _NPM: https://www.npmjs.com/get-npm -.. _PyPI: https://pypi.org/project/reactpy -.. _pip: https://pypi.org/project/pip/ -.. _PyTest: pytest <https://docs.pytest.org -.. _Playwright: https://playwright.dev/python/ -.. _React: https://reactjs.org/ -.. _Heroku: https://www.heroku.com/what -.. _GitHub Actions: https://github.com/features/actions -.. _pre-commit: https://pre-commit.com/ -.. _GitHub Flow: https://guides.github.com/introduction/flow/ -.. _MyPy: http://mypy-lang.org/ -.. _Black: https://github.com/psf/black -.. _Flake8: https://flake8.pycqa.org/en/latest/ -.. _Ruff: https://github.com/charliermarsh/ruff -.. _UVU: https://github.com/lukeed/uvu -.. _Prettier: https://prettier.io/ -.. _ESLint: https://eslint.org/ diff --git a/docs/source/about/credits-and-licenses.rst b/docs/source/about/credits-and-licenses.rst deleted file mode 100644 index bc66cb11f..000000000 --- a/docs/source/about/credits-and-licenses.rst +++ /dev/null @@ -1,16 +0,0 @@ -Credits and Licenses -==================== - -Much of this documentation, including its layout and content, was created with heavy -influence from https://reactjs.org which uses the `Creative Commons Attribution 4.0 -International -<https://raw.githubusercontent.com/reactjs/reactjs.org/b2d5613b6ae20855ced7c83067b604034bebbb44/LICENSE-DOCS.md>`__ -license. While many things have been transformed, we paraphrase and, in some places, -copy language or examples where ReactPy's behavior mirrors that of React's. - - -Source Code License -------------------- - -.. literalinclude:: ../../../LICENSE - :language: text diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 08addad8d..000000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,330 +0,0 @@ -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config - -import sys -from doctest import DONT_ACCEPT_TRUE_FOR_1, ELLIPSIS, NORMALIZE_WHITESPACE -from pathlib import Path - -# -- Path Setup -------------------------------------------------------------- - -THIS_DIR = Path(__file__).parent -ROOT_DIR = THIS_DIR.parent.parent -DOCS_DIR = THIS_DIR.parent - -# extension path -sys.path.insert(0, str(DOCS_DIR)) -sys.path.insert(0, str(THIS_DIR / "_exts")) - - -# -- Project information ----------------------------------------------------- - -project = "ReactPy" -title = "ReactPy" -description = ( - "ReactPy is a Python web framework for building interactive websites without needing " - "a single line of Javascript. It can be run standalone, in a Jupyter Notebook, or " - "as part of an existing application." -) -copyright = "2023, Ryan Morshead" # noqa: A001 -author = "Ryan Morshead" - -# -- Common External Links --------------------------------------------------- - -extlinks = { - "issue": ( - "https://github.com/reactive-python/reactpy/issues/%s", - "#%s", - ), - "pull": ( - "https://github.com/reactive-python/reactpy/pull/%s", - "#%s", - ), - "discussion": ( - "https://github.com/reactive-python/reactpy/discussions/%s", - "#%s", - ), - "discussion-type": ( - "https://github.com/reactive-python/reactpy/discussions/categories/%s", - "%s", - ), - "commit": ( - "https://github.com/reactive-python/reactpy/commit/%s", - "%s", - ), -} -extlinks_detect_hardcoded_links = True - - -# -- General configuration --------------------------------------------------- - -# If your documentatirston needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", - "sphinx.ext.coverage", - "sphinx.ext.viewcode", - "sphinx.ext.napoleon", - "sphinx.ext.extlinks", - # third party extensions - "sphinx_copybutton", - "sphinx_reredirects", - "sphinx_design", - "sphinxext.opengraph", - # custom extensions - "async_doctest", - "autogen_api_docs", - "copy_vdom_json_schema", - "reactpy_view", - "patched_html_translator", - "reactpy_example", - "build_custom_js", - "custom_autosectionlabel", -] - -# Add any paths that contain templates here, relative to this directory. -# templates_path = ["templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = "en" - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [ - "_custom_js", -] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = None - -# The default language to highlight source code in. -highlight_language = "python3" - -# Controls how sphinx.ext.autodoc represents typehints in the function signature -autodoc_typehints = "description" - -# -- Doc Test Configuration ------------------------------------------------------- - -doctest_default_flags = NORMALIZE_WHITESPACE | ELLIPSIS | DONT_ACCEPT_TRUE_FOR_1 - -# -- Extension Configuration ------------------------------------------------------ - - -# -- sphinx.ext.autosectionlabel --- - -autosectionlabel_skip_docs = ["_auto/apis"] - - -# -- sphinx.ext.autodoc -- - -# show base classes for autodoc -autodoc_default_options = { - "show-inheritance": True, - "member-order": "bysource", -} -# order autodoc members by their order in the source -autodoc_member_order = "bysource" - - -# -- sphinx_reredirects -- - -redirects = { - "package-api": "_autogen/user-apis.html", - "configuration-options": "_autogen/dev-apis.html#configuration-options", - "examples": "creating-interfaces/index.html", -} - - -# -- sphinxext.opengraph -- - -ogp_site_url = "https://reactpy.dev/" -ogp_image = "https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/png/reactpy-logo-landscape-padded.png" -# We manually specify this below -# ogp_description_length = 200 -ogp_type = "website" -ogp_custom_meta_tags = [ - # Open Graph Meta Tags - f'<meta property="og:title" content="{title}">', - f'<meta property="og:description" content="{description}">', - # Twitter Meta Tags - '<meta name="twitter:card" content="summary_large_image">', - '<meta name="twitter:creator" content="@rmorshea">', - '<meta name="twitter:site" content="@rmorshea">', -] - - -# -- Options for HTML output ------------------------------------------------- - -# Set the page title -html_title = title - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "furo" -html_logo = str(ROOT_DIR / "branding" / "svg" / "reactpy-logo-landscape.svg") -html_favicon = str(ROOT_DIR / "branding" / "ico" / "reactpy-logo.ico") - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. - -html_theme_options = { - "light_css_variables": { - # furo - "admonition-title-font-size": "1rem", - "admonition-font-size": "1rem", - # sphinx-design - "sd-color-info": "var(--color-admonition-title-background--note)", - "sd-color-warning": "var(--color-admonition-title-background--warning)", - "sd-color-danger": "var(--color-admonition-title-background--danger)", - "sd-color-info-text": "var(--color-admonition-title--note)", - "sd-color-warning-text": "var(--color-admonition-title--warning)", - "sd-color-danger-text": "var(--color-admonition-title--danger)", - }, -} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# These paths are either relative to html_static_path -# or fully qualified paths (eg. https://...) -css_dir = THIS_DIR / "_static" / "css" -html_css_files = [ - str(p.relative_to(THIS_DIR / "_static")) for p in css_dir.glob("*.css") -] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# -# html_sidebars = {} - - -# -- Options for Sphinx Panels ----------------------------------------------- - -panels_css_variables = { - "tabs-color-label-active": "rgb(106, 176, 221)", - "tabs-color-label-inactive": "rgb(201, 225, 250)", - "tabs-color-overline": "rgb(201, 225, 250)", - "tabs-color-underline": "rgb(201, 225, 250)", -} - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = "ReactPydoc" - - -# -- Options for LaTeX output ------------------------------------------------ - -# latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -# -# 'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -# -# 'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -# -# 'preamble': '', -# Latex figure (float) alignment -# -# 'figure_align': 'htbp', -# } - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [(master_doc, "ReactPy.tex", html_title, "Ryan Morshead", "manual")] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "reactpy", html_title, [author], 1)] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "ReactPy", - html_title, - author, - "ReactPy", - "One line description of project.", - "Miscellaneous", - ) -] - -# -- Options for Sphinx-Autodoc-Typehints output ------------------------------------------------- - -set_type_checking_flag = False - -# -- Options for Epub output ------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ["search.html"] - -# -- Options for intersphinx extension --------------------------------------- - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "pyalect": ("https://pyalect.readthedocs.io/en/latest", None), - "sanic": ("https://sanic.readthedocs.io/en/latest/", None), - "tornado": ("https://www.tornadoweb.org/en/stable/", None), - "flask": ("https://flask.palletsprojects.com/en/1.1.x/", None), -} - -# -- Options for todo extension ---------------------------------------------- - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/data.json b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/data.json deleted file mode 100644 index b1315912d..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/data.json +++ /dev/null @@ -1,72 +0,0 @@ -[ - { - "name": "Homenaje a la Neurocirugía", - "artist": "Marta Colvin Andrade", - "description": "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg/1024px-Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg", - "alt": "A bronze statue of two crossed hands delicately holding a human brain in their fingertips." - }, - { - "name": "Eternal Presence", - "artist": "John Woodrow Wilson", - "description": "Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as \"a symbolic Black presence infused with a sense of universal humanity.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/6/6f/Chicago%2C_Illinois_Eternal_Silence1_crop.jpg", - "alt": "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity." - }, - { - "name": "Moai", - "artist": "Unknown Artist", - "description": "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", - "url": "https://upload.wikimedia.org/wikipedia/commons/5/50/AhuTongariki.JPG", - "alt": "Three monumental stone busts with the heads that are disproportionately large with somber faces." - }, - { - "name": "Blue Nana", - "artist": "Niki de Saint Phalle", - "description": "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Blue_Nana_-_panoramio.jpg/1024px-Blue_Nana_-_panoramio.jpg", - "alt": "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy." - }, - { - "name": "Cavaliere", - "artist": "Lamidi Olonade Fakeye", - "description": "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", - "url": "https://upload.wikimedia.org/wikipedia/commons/3/34/Nigeria%2C_lamidi_olonade_fakeye%2C_cavaliere%2C_1992.jpg", - "alt": "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns." - }, - { - "name": "Big Bellies", - "artist": "Alina Szapocznikow", - "description": "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/KMM_Szapocznikow.JPG/200px-KMM_Szapocznikow.JPG", - "alt": "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures." - }, - { - "name": "Terracotta Army", - "artist": "Unknown Artist", - "description": "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg/1920px-2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg", - "alt": "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor." - }, - { - "name": "Lunar Landscape", - "artist": "Louise Nevelson", - "description": "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/1999-3-A--J_s.jpg/220px-1999-3-A--J_s.jpg", - "alt": "A black matte sculpture where the individual elements are initially indistinguishable." - }, - { - "name": "Aureole", - "artist": "Ranjani Shettar", - "description": "Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a \"fine synthesis of unlikely materials.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Shettar-_5854-sm_%285132866765%29.jpg/399px-Shettar-_5854-sm_%285132866765%29.jpg", - "alt": "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light." - }, - { - "name": "Hippos", - "artist": "Taipei Zoo", - "description": "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Hippo_sculpture_Taipei_Zoo_20543.jpg/250px-Hippo_sculpture_Taipei_Zoo_20543.jpg", - "alt": "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming." - } -] diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py deleted file mode 100644 index a919a2354..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -from pathlib import Path - -from reactpy import component, hooks, html, run - -HERE = Path(__file__) -DATA_PATH = HERE.parent / "data.json" -sculpture_data = json.loads(DATA_PATH.read_text()) - - -@component -def Gallery(): - index, set_index = hooks.use_state(0) - - def handle_click(event): - set_index(index + 1) - - bounded_index = index % len(sculpture_data) - sculpture = sculpture_data[bounded_index] - alt = sculpture["alt"] - artist = sculpture["artist"] - description = sculpture["description"] - name = sculpture["name"] - url = sculpture["url"] - - return html.div( - html.button({"on_click": handle_click}, "Next"), - html.h2(name, " by ", artist), - html.p(f"({bounded_index + 1} of {len(sculpture_data)})"), - html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), - html.p(description), - ) - - -run(Gallery) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/data.json b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/data.json deleted file mode 100644 index b1315912d..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/data.json +++ /dev/null @@ -1,72 +0,0 @@ -[ - { - "name": "Homenaje a la Neurocirugía", - "artist": "Marta Colvin Andrade", - "description": "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg/1024px-Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg", - "alt": "A bronze statue of two crossed hands delicately holding a human brain in their fingertips." - }, - { - "name": "Eternal Presence", - "artist": "John Woodrow Wilson", - "description": "Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as \"a symbolic Black presence infused with a sense of universal humanity.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/6/6f/Chicago%2C_Illinois_Eternal_Silence1_crop.jpg", - "alt": "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity." - }, - { - "name": "Moai", - "artist": "Unknown Artist", - "description": "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", - "url": "https://upload.wikimedia.org/wikipedia/commons/5/50/AhuTongariki.JPG", - "alt": "Three monumental stone busts with the heads that are disproportionately large with somber faces." - }, - { - "name": "Blue Nana", - "artist": "Niki de Saint Phalle", - "description": "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Blue_Nana_-_panoramio.jpg/1024px-Blue_Nana_-_panoramio.jpg", - "alt": "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy." - }, - { - "name": "Cavaliere", - "artist": "Lamidi Olonade Fakeye", - "description": "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", - "url": "https://upload.wikimedia.org/wikipedia/commons/3/34/Nigeria%2C_lamidi_olonade_fakeye%2C_cavaliere%2C_1992.jpg", - "alt": "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns." - }, - { - "name": "Big Bellies", - "artist": "Alina Szapocznikow", - "description": "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/KMM_Szapocznikow.JPG/200px-KMM_Szapocznikow.JPG", - "alt": "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures." - }, - { - "name": "Terracotta Army", - "artist": "Unknown Artist", - "description": "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg/1920px-2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg", - "alt": "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor." - }, - { - "name": "Lunar Landscape", - "artist": "Louise Nevelson", - "description": "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/1999-3-A--J_s.jpg/220px-1999-3-A--J_s.jpg", - "alt": "A black matte sculpture where the individual elements are initially indistinguishable." - }, - { - "name": "Aureole", - "artist": "Ranjani Shettar", - "description": "Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a \"fine synthesis of unlikely materials.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Shettar-_5854-sm_%285132866765%29.jpg/399px-Shettar-_5854-sm_%285132866765%29.jpg", - "alt": "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light." - }, - { - "name": "Hippos", - "artist": "Taipei Zoo", - "description": "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Hippo_sculpture_Taipei_Zoo_20543.jpg/250px-Hippo_sculpture_Taipei_Zoo_20543.jpg", - "alt": "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming." - } -] diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py deleted file mode 100644 index d07b87140..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -from pathlib import Path - -from reactpy import component, hooks, html, run - -HERE = Path(__file__) -DATA_PATH = HERE.parent / "data.json" -sculpture_data = json.loads(DATA_PATH.read_text()) - - -@component -def Gallery(): - index, set_index = hooks.use_state(0) - show_more, set_show_more = hooks.use_state(False) - - def handle_next_click(event): - set_index(index + 1) - - def handle_more_click(event): - set_show_more(not show_more) - - bounded_index = index % len(sculpture_data) - sculpture = sculpture_data[bounded_index] - alt = sculpture["alt"] - artist = sculpture["artist"] - description = sculpture["description"] - name = sculpture["name"] - url = sculpture["url"] - - return html.div( - html.button({"on_click": handle_next_click}, "Next"), - html.h2(name, " by ", artist), - html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), - html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), - html.div( - html.button( - {"on_click": handle_more_click}, - f"{('Show' if show_more else 'Hide')} details", - ), - (html.p(description) if show_more else ""), - ), - ) - - -@component -def App(): - return html.div( - html.section({"style": {"width": "50%", "float": "left"}}, Gallery()), - html.section({"style": {"width": "50%", "float": "left"}}, Gallery()), - ) - - -run(App) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/data.json b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/data.json deleted file mode 100644 index b1315912d..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/data.json +++ /dev/null @@ -1,72 +0,0 @@ -[ - { - "name": "Homenaje a la Neurocirugía", - "artist": "Marta Colvin Andrade", - "description": "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg/1024px-Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg", - "alt": "A bronze statue of two crossed hands delicately holding a human brain in their fingertips." - }, - { - "name": "Eternal Presence", - "artist": "John Woodrow Wilson", - "description": "Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as \"a symbolic Black presence infused with a sense of universal humanity.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/6/6f/Chicago%2C_Illinois_Eternal_Silence1_crop.jpg", - "alt": "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity." - }, - { - "name": "Moai", - "artist": "Unknown Artist", - "description": "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", - "url": "https://upload.wikimedia.org/wikipedia/commons/5/50/AhuTongariki.JPG", - "alt": "Three monumental stone busts with the heads that are disproportionately large with somber faces." - }, - { - "name": "Blue Nana", - "artist": "Niki de Saint Phalle", - "description": "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Blue_Nana_-_panoramio.jpg/1024px-Blue_Nana_-_panoramio.jpg", - "alt": "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy." - }, - { - "name": "Cavaliere", - "artist": "Lamidi Olonade Fakeye", - "description": "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", - "url": "https://upload.wikimedia.org/wikipedia/commons/3/34/Nigeria%2C_lamidi_olonade_fakeye%2C_cavaliere%2C_1992.jpg", - "alt": "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns." - }, - { - "name": "Big Bellies", - "artist": "Alina Szapocznikow", - "description": "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/KMM_Szapocznikow.JPG/200px-KMM_Szapocznikow.JPG", - "alt": "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures." - }, - { - "name": "Terracotta Army", - "artist": "Unknown Artist", - "description": "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg/1920px-2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg", - "alt": "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor." - }, - { - "name": "Lunar Landscape", - "artist": "Louise Nevelson", - "description": "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/1999-3-A--J_s.jpg/220px-1999-3-A--J_s.jpg", - "alt": "A black matte sculpture where the individual elements are initially indistinguishable." - }, - { - "name": "Aureole", - "artist": "Ranjani Shettar", - "description": "Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a \"fine synthesis of unlikely materials.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Shettar-_5854-sm_%285132866765%29.jpg/399px-Shettar-_5854-sm_%285132866765%29.jpg", - "alt": "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light." - }, - { - "name": "Hippos", - "artist": "Taipei Zoo", - "description": "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Hippo_sculpture_Taipei_Zoo_20543.jpg/250px-Hippo_sculpture_Taipei_Zoo_20543.jpg", - "alt": "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming." - } -] diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py deleted file mode 100644 index 87f9651be..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py +++ /dev/null @@ -1,45 +0,0 @@ -import json -from pathlib import Path - -from reactpy import component, hooks, html, run - -HERE = Path(__file__) -DATA_PATH = HERE.parent / "data.json" -sculpture_data = json.loads(DATA_PATH.read_text()) - - -@component -def Gallery(): - index, set_index = hooks.use_state(0) - show_more, set_show_more = hooks.use_state(False) - - def handle_next_click(event): - set_index(index + 1) - - def handle_more_click(event): - set_show_more(not show_more) - - bounded_index = index % len(sculpture_data) - sculpture = sculpture_data[bounded_index] - alt = sculpture["alt"] - artist = sculpture["artist"] - description = sculpture["description"] - name = sculpture["name"] - url = sculpture["url"] - - return html.div( - html.button({"on_click": handle_next_click}, "Next"), - html.h2(name, " by ", artist), - html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), - html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), - html.div( - html.button( - {"on_click": handle_more_click}, - f"{('Show' if show_more else 'Hide')} details", - ), - (html.p(description) if show_more else ""), - ), - ) - - -run(Gallery) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/data.json b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/data.json deleted file mode 100644 index b1315912d..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/data.json +++ /dev/null @@ -1,72 +0,0 @@ -[ - { - "name": "Homenaje a la Neurocirugía", - "artist": "Marta Colvin Andrade", - "description": "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg/1024px-Homenaje_a_la_Neurocirug%C3%ADa%2C_Instituto_de_Neurocirug%C3%ADa%2C_Providencia%2C_Santiago_20200106_02.jpg", - "alt": "A bronze statue of two crossed hands delicately holding a human brain in their fingertips." - }, - { - "name": "Eternal Presence", - "artist": "John Woodrow Wilson", - "description": "Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as \"a symbolic Black presence infused with a sense of universal humanity.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/6/6f/Chicago%2C_Illinois_Eternal_Silence1_crop.jpg", - "alt": "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity." - }, - { - "name": "Moai", - "artist": "Unknown Artist", - "description": "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", - "url": "https://upload.wikimedia.org/wikipedia/commons/5/50/AhuTongariki.JPG", - "alt": "Three monumental stone busts with the heads that are disproportionately large with somber faces." - }, - { - "name": "Blue Nana", - "artist": "Niki de Saint Phalle", - "description": "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Blue_Nana_-_panoramio.jpg/1024px-Blue_Nana_-_panoramio.jpg", - "alt": "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy." - }, - { - "name": "Cavaliere", - "artist": "Lamidi Olonade Fakeye", - "description": "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", - "url": "https://upload.wikimedia.org/wikipedia/commons/3/34/Nigeria%2C_lamidi_olonade_fakeye%2C_cavaliere%2C_1992.jpg", - "alt": "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns." - }, - { - "name": "Big Bellies", - "artist": "Alina Szapocznikow", - "description": "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/KMM_Szapocznikow.JPG/200px-KMM_Szapocznikow.JPG", - "alt": "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures." - }, - { - "name": "Terracotta Army", - "artist": "Unknown Artist", - "description": "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg/1920px-2015-09-22-081415_-_Terrakotta-Armee%2C_Grosse_Halle.jpg", - "alt": "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor." - }, - { - "name": "Lunar Landscape", - "artist": "Louise Nevelson", - "description": "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/1999-3-A--J_s.jpg/220px-1999-3-A--J_s.jpg", - "alt": "A black matte sculpture where the individual elements are initially indistinguishable." - }, - { - "name": "Aureole", - "artist": "Ranjani Shettar", - "description": "Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a \"fine synthesis of unlikely materials.\"", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Shettar-_5854-sm_%285132866765%29.jpg/399px-Shettar-_5854-sm_%285132866765%29.jpg", - "alt": "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light." - }, - { - "name": "Hippos", - "artist": "Taipei Zoo", - "description": "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", - "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Hippo_sculpture_Taipei_Zoo_20543.jpg/250px-Hippo_sculpture_Taipei_Zoo_20543.jpg", - "alt": "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming." - } -] diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py deleted file mode 100644 index c617586de..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py +++ /dev/null @@ -1,42 +0,0 @@ -# flake8: noqa -# errors F841,F823 for `index = index + 1` inside the closure - -# :lines: 7- -# :linenos: - -import json -from pathlib import Path - -from reactpy import component, html, run - - -HERE = Path(__file__) -DATA_PATH = HERE.parent / "data.json" -sculpture_data = json.loads(DATA_PATH.read_text()) - - -@component -def Gallery(): - index = 0 - - def handle_click(event): - index = index + 1 - - bounded_index = index % len(sculpture_data) - sculpture = sculpture_data[bounded_index] - alt = sculpture["alt"] - artist = sculpture["artist"] - description = sculpture["description"] - name = sculpture["name"] - url = sculpture["url"] - - return html.div( - html.button({"on_click": handle_click}, "Next"), - html.h2(name, " by ", artist), - html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), - html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), - html.p(description), - ) - - -run(Gallery) diff --git a/docs/source/guides/adding-interactivity/components-with-state/index.rst b/docs/source/guides/adding-interactivity/components-with-state/index.rst deleted file mode 100644 index f8235ac0d..000000000 --- a/docs/source/guides/adding-interactivity/components-with-state/index.rst +++ /dev/null @@ -1,351 +0,0 @@ -Components With State -===================== - -Components often need to change what’s on the screen as a result of an interaction. For -example, typing into the form should update the input field, clicking “next” on an image -carousel should change which image is displayed, clicking “buy” should put a product in -the shopping cart. Components need to “remember” things like the current input value, -the current image, the shopping cart. In ReactPy, this kind of component-specific memory is -called state. - - -When Variables Aren't Enough ----------------------------- - -Below is a gallery of images about sculpture. Clicking the "Next" button should -increment the ``index`` and, as a result, change what image is displayed. However, this -does not work: - -.. reactpy:: _examples/when_variables_are_not_enough - -.. note:: - - Try clicking the button to see that it does not cause a change. - -After clicking "Next", if you check your web server's logs, you'll discover an -``UnboundLocalError`` error. It turns out that in this case, the ``index = index + 1`` -statement is similar to `trying to set global variables -<https://stackoverflow.com/questions/9264763/dont-understand-why-unboundlocalerror-occurs-closure>`__. -Technically there's a way to `fix this error -<https://docs.python.org/3/reference/simple_stmts.html#nonlocal>`__, but even if we did, -that still wouldn't fix the underlying problems: - -1. **Local variables do not persist across component renders** - when a component is - updated, its associated function gets called again. That is, it renders. As a result, - all the local state that was created the last time the function was called gets - destroyed when it updates. - -2. **Changes to local variables do not cause components to re-render** - there's no way - for ReactPy to observe when these variables change. Thus ReactPy is not aware that - something has changed and that a re-render should take place. - -To address these problems, ReactPy provides the :func:`~reactpy.core.hooks.use_state` "hook" -which provides: - -1. A **state variable** whose data is retained across renders. - -2. A **state setter** function that can be used to update that variable and trigger a - render. - - -Adding State to Components --------------------------- - -To create a state variable and state setter with :func:`~reactpy.core.hooks.use_state` hook -as described above, we'll begin by importing it: - -.. testcode:: - - from reactpy import use_state - -Then we'll make the following changes to our code :ref:`from before <When Variables -Aren't Enough>`: - -.. code-block:: diff - - - index = 0 - + index, set_index = use_state - - def handle_click(event): - - index = index + 1 - + set_index(index + 1) - -After making those changes we should get: - -.. code-block:: - :linenos: - :lineno-start: 14 - - index, set_index = use_state(0) - - def handle_click(event): - set_index(index + 1) - -We'll talk more about what this is doing :ref:`shortly <your first hook>`, but for -now let's just verify that this does in fact fix the problems from before: - -.. reactpy:: _examples/adding_state_variable - - -Your First Hook ---------------- - -In ReactPy, ``use_state``, as well as any other function whose name starts with ``use``, is -called a "hook". These are special functions that should only be called while ReactPy is -:ref:`rendering <the rendering process>`. They let you "hook into" the different -capabilities of ReactPy's components of which ``use_state`` is just one (well get into the -other :ref:`later <managing state>`). - -While hooks are just normal functions, but it's helpful to think of them as -:ref:`unconditioned <rules of hooks>` declarations about a component's needs. In other -words, you'll "use" hooks at the top of your component in the same way you might -"import" modules at the top of your Python files. - - -.. _Introduction to use_state: - -Introduction to ``use_state`` ------------------------------ - -When you call :func:`~reactpy.core.hooks.use_state` inside the body of a component's render -function, you're declaring that this component needs to remember something. That -"something" which needs to be remembered, is known as **state**. So when we look at an -assignment expression like the one below - -.. code-block:: - - index, set_index = use_state(0) - -we should read it as saying that ``index`` is a piece of state which must be -remembered by the component that declared it. The argument to ``use_state`` (in this -case ``0``) is then conveying what the initial value for ``index`` is. - -We should then understand that each time the component which owns this state renders -``use_state`` will return a tuple containing two values - the current value of the state -(``index``) and a function to change that value the next time the component is rendered. -Thus, in this example: - -- ``index`` - is a **state variable** containing the currently stored value. -- ``set_index`` - is a **state setter** for changing that value and triggering a re-render - of the component. - -The convention is that, if you name your state variable ``thing``, your state setter -should be named ``set_thing``. While you could name them anything you want, adhering to -the convention makes things easier to understand across projects. - ----- - -To understand how this works in context, let's break down our example by examining key -moments in the execution of the ``Gallery`` component. Each numbered tab in the section -below highlights a line of code where something of interest occurs: - -.. hint:: - - Try clicking through the numbered tabs to each highlighted step of execution - -.. tab-set:: - - .. tab-item:: 1 - - .. raw:: html - - <h2>Initial render</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 2 - - At this point, we've just begun to render the ``Gallery`` component. As yet, - ReactPy is not aware that this component has any state or what view it will - display. This will change in a moment though when we move to the next line... - - .. tab-item:: 2 - - .. raw:: html - - <h2>Initial state declaration</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 3 - - The ``Gallery`` component has just declared some state. ReactPy now knows that it - must remember the ``index`` and trigger an update of this component when - ``set_index`` is called. Currently the value of ``index`` is ``0`` as per the - default value given to ``use_state``. Thus, the resulting view will display - information about the first item in our ``sculpture_data`` list. - - .. tab-item:: 3 - - .. raw:: html - - <h2>Define event handler</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 5 - - We've now defined an event handler that we intend to assign to a button in the - view. This will respond once the user clicks that button. The action this - handler performs is to update the value of ``index`` and schedule our ``Gallery`` - component to update. - - .. tab-item:: 4 - - .. raw:: html - - <h2>Return the view</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 16 - - The ``handle_click`` function we defined above has now been assigned to a button - in the view and we are about to display information about the first item in out - ``sculpture_data`` list. When the view is ultimately displayed, if a user clicks - the "Next" button, the handler we just assigned will be triggered. Until that - point though, the application will remain static. - - .. tab-item:: 5 - - .. raw:: html - - <h2>User interaction</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 5 - - A user has just clicked the button 🖱️! ReactPy has sent information about the event - to the ``handle_click`` function and it is about to execute. In a moment we will - update the state of this component and schedule a re-render. - - .. tab-item:: 6 - - .. raw:: html - - <h2>New state is set</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 6 - - We've just now told ReactPy that we want to update the state of our ``Gallery`` and - that it needs to be re-rendered. More specifically, we are incrementing its - ``index``, and once ``Gallery`` re-renders the index *will* be ``1``. - Importantly, at this point, the value of ``index`` is still ``0``! This will - only change once the component begins to re-render. - - .. tab-item:: 7 - - .. raw:: html - - <h2>Next render begins</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 2 - - The scheduled re-render of ``Gallery`` has just begun. ReactPy has now updated its - internal state store such that, the next time we call ``use_state`` we will get - back the updated value of ``index``. - - .. tab-item:: 8 - - .. raw:: html - - <h2>Next state is acquired</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - :emphasize-lines: 3 - - With ReactPy's state store updated, as we call ``use_state``, instead of returning - ``0`` for the value of ``index`` as it did before, ReactPy now returns the value - ``1``. With this change the view we display will be altered - instead of - displaying data for the first item in our ``sculpture_data`` list we will now - display information about the second. - - .. tab-item:: 9 - - .. raw:: html - - <h2>Repeat...</h2> - - .. literalinclude:: _examples/adding_state_variable/main.py - :lines: 12-33 - - From this point on, the steps remain the same. The only difference being the - progressively incrementing ``index`` each time the user clicks the "Next" button - and the view which is altered to to reflect the currently indexed item in the - ``sculpture_data`` list. - - .. note:: - - Once we reach the end of the ``sculpture_data`` list the view will return - back to the first item since we create a ``bounded_index`` by doing a modulo - of the index with the length of the list (``index % len(sculpture_data)``). - Ideally we would do this bounding at the time we call ``set_index`` to - prevent ``index`` from incrementing to infinity, but to keep things simple - in this examples, we've kept this logic separate. - - -Multiple State Declarations ---------------------------- - -The powerful thing about hooks like :func:`~reactpy.core.hooks.use_state` is that you're -not limited to just one state declaration. You can call ``use_state()`` as many times as -you need to in one component. For example, in the example below we've added a -``show_more`` state variable along with a few other modifications (e.g. renaming -``handle_click``) to make the description for each sculpture optionally displayed. Only -when the user clicks the "Show details" button is this description shown: - -.. reactpy:: _examples/multiple_state_variables - -It's generally a good idea to define separate state variables if the data they represent -is unrelated. In this case, ``index`` corresponds to what sculpture information is being -displayed and ``show_more`` is solely concerned with whether the description for a given -sculpture is shown. Put other way ``index`` is concerned with *what* information is -displayed while ``show_more`` is concerned with *how* it is displayed. Conversely -though, if you have a form with many fields, it probably makes sense to have a single -object that holds the data for all the fields rather than an object per-field. - -.. note:: - - This topic is discussed more in the :ref:`structuring your state` section. - - -State is Isolated and Private ------------------------------ - -State is local to a component instance on the screen. In other words, if you render the -same component twice, each copy will have completely isolated state! Changing one of -them will not affect the other. - -In this example, the ``Gallery`` component from earlier is rendered twice with no -changes to its logic. Try clicking the buttons inside each of the galleries. Notice that -their state is independent: - -.. reactpy:: _examples/isolated_state - :result-is-default-tab: - -This is what makes state different from regular variables that you might declare at the -top of your module. State is not tied to a particular function call or a place in the -code, but it’s “local” to the specific place on the screen. You rendered two ``Gallery`` -components, so their state is stored separately. - -Also notice how the Page component doesn’t “know” anything about the Gallery state or -even whether it has any. Unlike props, state is fully private to the component declaring -it. The parent component can’t change it. This lets you add state to any component or -remove it without impacting the rest of the components. - -.. card:: - :link: /guides/managing-state/sharing-component-state/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - What if you wanted both galleries to keep their states in sync? The right way to do - it in ReactPy is to remove state from child components and add it to their closest - shared parent. diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py deleted file mode 100644 index 6c3c783da..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py +++ /dev/null @@ -1,58 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def Definitions(): - term_to_add, set_term_to_add = use_state(None) - definition_to_add, set_definition_to_add = use_state(None) - all_terms, set_all_terms = use_state({}) - - def handle_term_to_add_change(event): - set_term_to_add(event["target"]["value"]) - - def handle_definition_to_add_change(event): - set_definition_to_add(event["target"]["value"]) - - def handle_add_click(event): - if term_to_add and definition_to_add: - set_all_terms({**all_terms, term_to_add: definition_to_add}) - set_term_to_add(None) - set_definition_to_add(None) - - def make_delete_click_handler(term_to_delete): - def handle_click(event): - set_all_terms({t: d for t, d in all_terms.items() if t != term_to_delete}) - - return handle_click - - return html.div( - html.button({"on_click": handle_add_click}, "add term"), - html.label( - "Term: ", - html.input({"value": term_to_add, "on_change": handle_term_to_add_change}), - ), - html.label( - "Definition: ", - html.input( - { - "value": definition_to_add, - "on_change": handle_definition_to_add_change, - } - ), - ), - html.hr(), - [ - html.div( - {"key": term}, - html.button( - {"on_click": make_delete_click_handler(term)}, "delete term" - ), - html.dt(term), - html.dd(definition), - ) - for term, definition in all_terms.items() - ], - ) - - -run(Definitions) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py deleted file mode 100644 index 32dd4073a..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py +++ /dev/null @@ -1,44 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def Form(): - person, set_person = use_state( - { - "first_name": "Barbara", - "last_name": "Hepworth", - "email": "bhepworth@sculpture.com", - } - ) - - def handle_first_name_change(event): - set_person({**person, "first_name": event["target"]["value"]}) - - def handle_last_name_change(event): - set_person({**person, "last_name": event["target"]["value"]}) - - def handle_email_change(event): - set_person({**person, "email": event["target"]["value"]}) - - return html.div( - html.label( - "First name: ", - html.input( - {"value": person["first_name"], "on_change": handle_first_name_change} - ), - ), - html.label( - "Last name: ", - html.input( - {"value": person["last_name"], "on_change": handle_last_name_change} - ), - ), - html.label( - "Email: ", - html.input({"value": person["email"], "on_change": handle_email_change}), - ), - html.p(f"{person['first_name']} {person['last_name']} {person['email']}"), - ) - - -run(Form) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py deleted file mode 100644 index 1f4072e0b..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py +++ /dev/null @@ -1,25 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def ArtistList(): - artist_to_add, set_artist_to_add = use_state("") - artists, set_artists = use_state([]) - - def handle_change(event): - set_artist_to_add(event["target"]["value"]) - - def handle_click(event): - if artist_to_add and artist_to_add not in artists: - set_artists([*artists, artist_to_add]) - set_artist_to_add("") - - return html.div( - html.h1("Inspiring sculptors:"), - html.input({"value": artist_to_add, "on_change": handle_change}), - html.button({"on_click": handle_click}, "add"), - html.ul([html.li({"key": name}, name) for name in artists]), - ) - - -run(ArtistList) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py deleted file mode 100644 index 3bd2fd601..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py +++ /dev/null @@ -1,24 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def ArtistList(): - artists, set_artists = use_state( - ["Marta Colvin Andrade", "Lamidi Olonade Fakeye", "Louise Nevelson"] - ) - - def handle_sort_click(event): - set_artists(sorted(artists)) - - def handle_reverse_click(event): - set_artists(list(reversed(artists))) - - return html.div( - html.h1("Inspiring sculptors:"), - html.button({"on_click": handle_sort_click}, "sort"), - html.button({"on_click": handle_reverse_click}, "reverse"), - html.ul([html.li({"key": name}, name) for name in artists]), - ) - - -run(ArtistList) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py deleted file mode 100644 index 6223284f6..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py +++ /dev/null @@ -1,44 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def ArtistList(): - artist_to_add, set_artist_to_add = use_state("") - artists, set_artists = use_state( - ["Marta Colvin Andrade", "Lamidi Olonade Fakeye", "Louise Nevelson"] - ) - - def handle_change(event): - set_artist_to_add(event["target"]["value"]) - - def handle_add_click(event): - if artist_to_add not in artists: - set_artists([*artists, artist_to_add]) - set_artist_to_add("") - - def make_handle_delete_click(index): - def handle_click(event): - set_artists(artists[:index] + artists[index + 1 :]) - - return handle_click - - return html.div( - html.h1("Inspiring sculptors:"), - html.input({"value": artist_to_add, "on_change": handle_change}), - html.button({"on_click": handle_add_click}, "add"), - html.ul( - [ - html.li( - {"key": name}, - name, - html.button( - {"on_click": make_handle_delete_click(index)}, "delete" - ), - ) - for index, name in enumerate(artists) - ] - ), - ) - - -run(ArtistList) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py deleted file mode 100644 index 4952b9597..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py +++ /dev/null @@ -1,27 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def CounterList(): - counters, set_counters = use_state([0, 0, 0]) - - def make_increment_click_handler(index): - def handle_click(event): - new_value = counters[index] + 1 - set_counters(counters[:index] + [new_value] + counters[index + 1 :]) - - return handle_click - - return html.ul( - [ - html.li( - {"key": index}, - count, - html.button({"on_click": make_increment_click_handler(index)}, "+1"), - ) - for index, count in enumerate(counters) - ] - ) - - -run(CounterList) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py deleted file mode 100644 index e5ab54dca..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py +++ /dev/null @@ -1,45 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def MovingDot(): - position, set_position = use_state({"x": 0, "y": 0}) - - async def handle_pointer_move(event): - outer_div_info = event["currentTarget"] - outer_div_bounds = outer_div_info["boundingClientRect"] - set_position( - { - "x": event["clientX"] - outer_div_bounds["x"], - "y": event["clientY"] - outer_div_bounds["y"], - } - ) - - return html.div( - { - "on_pointer_move": handle_pointer_move, - "style": { - "position": "relative", - "height": "200px", - "width": "100%", - "background_color": "white", - }, - }, - html.div( - { - "style": { - "position": "absolute", - "background_color": "red", - "border_radius": "50%", - "width": "20px", - "height": "20px", - "left": "-10px", - "top": "-10px", - "transform": f"translate({position['x']}px, {position['y']}px)", - } - } - ), - ) - - -run(MovingDot) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py deleted file mode 100644 index 8972ce74e..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py +++ /dev/null @@ -1,43 +0,0 @@ -# :linenos: - -from reactpy import component, html, run, use_state - - -@component -def MovingDot(): - position, _ = use_state({"x": 0, "y": 0}) - - def handle_pointer_move(event): - outer_div_info = event["currentTarget"] - outer_div_bounds = outer_div_info["boundingClientRect"] - position["x"] = event["clientX"] - outer_div_bounds["x"] - position["y"] = event["clientY"] - outer_div_bounds["y"] - - return html.div( - { - "on_pointer_move": handle_pointer_move, - "style": { - "position": "relative", - "height": "200px", - "width": "100%", - "background_color": "white", - }, - }, - html.div( - { - "style": { - "position": "absolute", - "background_color": "red", - "border_radius": "50%", - "width": "20px", - "height": "20px", - "left": "-10px", - "top": "-10px", - "transform": f"translate({position['x']}px, {position['y']}px)", - } - } - ), - ) - - -run(MovingDot) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py deleted file mode 100644 index be5366cb2..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ /dev/null @@ -1,41 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def Grid(): - line_size = 5 - selected_indices, set_selected_indices = use_state({1, 2, 4}) - - def make_handle_click(index): - def handle_click(event): - if index in selected_indices: - set_selected_indices(selected_indices - {index}) - else: - set_selected_indices(selected_indices | {index}) - - return handle_click - - return html.div( - {"style": {"display": "flex", "flex-direction": "row"}}, - [ - html.div( - { - "on_click": make_handle_click(index), - "style": { - "height": "30px", - "width": "30px", - "background_color": "black" - if index in selected_indices - else "white", - "outline": "1px solid grey", - "cursor": "pointer", - }, - "key": index, - } - ) - for index in range(line_size) - ], - ) - - -run(Grid) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py deleted file mode 100644 index 8ff2e1ca4..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ /dev/null @@ -1,38 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def Grid(): - line_size = 5 - selected_indices, set_selected_indices = use_state(set()) - - def make_handle_click(index): - def handle_click(event): - set_selected_indices(selected_indices | {index}) - - return handle_click - - return html.div( - {"style": {"display": "flex", "flex-direction": "row"}}, - [ - html.div( - { - "on_click": make_handle_click(index), - "style": { - "height": "30px", - "width": "30px", - "background_color": "black" - if index in selected_indices - else "white", - "outline": "1px solid grey", - "cursor": "pointer", - }, - "key": index, - } - ) - for index in range(line_size) - ], - ) - - -run(Grid) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/index.rst b/docs/source/guides/adding-interactivity/dangers-of-mutability/index.rst deleted file mode 100644 index bcac79e18..000000000 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/index.rst +++ /dev/null @@ -1,569 +0,0 @@ -Dangers of Mutability -===================== - -While state can hold any type of value, you should be careful to avoid directly -modifying objects that you declare as state with ReactPy. In other words, you must not -:ref:`"mutate" <What is a Mutation>` values which are held as state. Rather, to change -these values you should use new ones or create copies. - - -.. _what is a mutation: - -What is a Mutation? -------------------- - -In Python, values may be either "mutable" or "immutable". Mutable objects are those -whose underlying data can be changed after they are created, and immutable objects are -those which cannot. A "mutation" then, is the act of changing the underlying data of a -mutable value. In particular, a :class:`dict` is a mutable type of value. In the code -below, an initially empty dictionary is created. Then, a key and value is added to it: - -.. code-block:: - - x = {} - x["a"] = 1 - assert x == {"a": 1} - -This is different from something like a :class:`str` which is immutable. Instead of -modifying the underlying data of an existing value, a new one must be created to -facilitate change: - -.. code-block:: - - x = "Hello" - y = x + " world!" - assert x is not y - -.. note:: - - In Python, the ``is`` and ``is not`` operators check whether two values are - identitcal. This `is distinct - <https://realpython.com/python-is-identity-vs-equality>`__ from checking whether two - values are equivalent with the ``==`` or ``!=`` operators. - -Thus far, all the values we've been working with have been immutable. These include -:class:`int`, :class:`float`, :class:`str`, and :class:`bool` values. As a result, we -have not had to consider the consequences of mutations. - - -.. _Why Avoid Mutation: - -Why Avoid Mutation? -------------------- - -Unfortunately, ReactPy does not understand that when a value is mutated, it may have -changed. As a result, mutating values will not trigger re-renders. Thus, you must be -careful to avoid mutation whenever you want ReactPy to re-render a component. For example, -the intention of the code below is to make the red dot move when you touch or hover over -the preview area. However it doesn't - the dot remains stationary: - -.. reactpy:: _examples/moving_dot_broken - -The problem is with this section of code: - -.. literalinclude:: _examples/moving_dot_broken.py - :language: python - :lines: 13-14 - :linenos: - :lineno-start: 13 - -This code mutates the ``position`` dictionary from the prior render instead of using the -state variable's associated state setter. Without calling setter ReactPy has no idea that -the variable's data has been modified. While it can be possible to get away with -mutating state variables, it's highly dicsouraged. Doing so can cause strange and -unpredictable behavior. As a result, you should always treat the data within a state -variable as immutable. - -To actually trigger a render we need to call the state setter. To do that we'll assign -it to ``set_position`` instead of the unused ``_`` variable we have above. Then we can -call it by passing a *new* dictionary with the values for the next render. Notice how, -by making these alterations to the code, that the dot now follows your pointer when -you touch or hover over the preview: - -.. reactpy:: _examples/moving_dot - - -.. dropdown:: Local mutation can be alright - :color: info - :animate: fade-in - - While code like this causes problems: - - .. code-block:: - - position["x"] = event["clientX"] - outer_div_bounds["x"] - position["y"] = event["clientY"] - outer_div_bounds["y"] - - It's ok if you mutate a fresh dictionary that you have *just* created before calling - the state setter: - - .. code-block:: - - new_position = {} - new_position["x"] = event["clientX"] - outer_div_bounds["x"] - new_position["y"] = event["clientY"] - outer_div_bounds["y"] - set_position(new_position) - - It's actually nearly equivalent to having written: - - .. code-block:: - - set_position( - { - "x": event["clientX"] - outer_div_bounds["x"], - "y": event["clientY"] - outer_div_bounds["y"], - } - ) - - Mutation is only a problem when you change data assigned to existing state - variables. Mutating an object you’ve just created is okay because no other code - references it yet. Changing it isn’t going to accidentally impact something that - depends on it. This is called a “local mutation.” You can even do local mutation - while rendering. Very convenient and completely okay! - - -Working with Dictionaries -------------------------- - -Below are some ways to update dictionaries without mutating them: - -.. card:: Updating Items - :link: updating-dictionary-items - :link-type: ref - - Avoid using item assignment, ``dict.update``, or ``dict.setdefault``. Instead try - the strategies below: - - .. code-block:: - - {**d, "key": value} - - # Python >= 3.9 - d | {"key": value} - - # Equivalent to dict.setdefault() - {"key": value, **d} - -.. card:: Removing Items - :link: removing-dictionary-items - :link-type: ref - - Avoid using item deletion or ``dict.pop``. Instead try the strategies below: - - .. code-block:: - - { - k: v - for k, v in d.items() - if k != key - } - - # Better for removing multiple items - { - k: d[k] - for k in set(d).difference([key]) - } - - ----- - - -.. _updating-dictionary-items: - -Updating Dictionary Items -......................... - -.. grid:: 1 1 1 2 - :gutter: 1 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - d[key] = value - - d.update({key: value}) - - d.setdefault(key, value) - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - {**d, key: value} - - # Python >= 3.9 - d | {key: value} - - # Equivalent to setdefault() - {key: value, **d} - -As we saw in an :ref:`earlier example <why avoid mutation>`, instead of mutating -dictionaries to update their items you should instead create a copy that contains the -desired changes. - -However, sometimes you may only want to update some of the information in a dictionary -which is held by a state variable. Consider the case below where we have a form for -updating user information with a preview of the currently entered data. We can -accomplish this using `"unpacking" <https://www.python.org/dev/peps/pep-0448/>`__ with -the ``**`` syntax: - -.. reactpy:: _examples/dict_update - - -.. _removing-dictionary-items: - -Removing Dictionary Items -......................... - -.. grid:: 1 1 1 2 - :gutter: 1 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - del d[key] - - d.pop(key) - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - { - k: v - for k, v in d.items() - if k != key - } - - # Better for removing multiple items - { - k: d[k] - for k in set(d).difference([key]) - } - -This scenario doesn't come up very frequently. When it does though, the best way to -remove items from dictionaries is to create a copy of the original, but with a filtered -set of keys. One way to do this is with a dictionary comprehension. The example below -shows an interface where you're able to enter a new term and definition. Once added, -you can click a delete button to remove the term and definition: - -.. reactpy:: _examples/dict_remove - - -Working with Lists ------------------- - -Below are some ways to update lists without mutating them: - -.. card:: Inserting Items - :link: inserting-list-items - :link-type: ref - - Avoid using ``list.append``, ``list.extend``, and ``list.insert``. Instead try the - strategies below: - - .. code-block:: - - [*l, value] - - l + [value] - - l + values - - l[:index] + [value] + l[index:] - -.. card:: Removing Items - :link: removing-list-items - :link-type: ref - - Avoid using item deletion or ``list.pop``. Instead try the strategy below: - - .. code-block:: - - l[:index - 1] + l[index:] - -.. card:: Replacing Items - :link: replacing-list-items - :link-type: ref - - Avoid using item or slice assignment. Instead try the strategies below: - - .. code-block:: - - l[:index] + [value] + l[index + 1:] - - l[:start] + values + l[end + 1:] - -.. card:: Re-ordering Items - :link: re-ordering-list-items - :link-type: ref - - Avoid using ``list.sort`` or ``list.reverse``. Instead try the strategies below: - - .. code-block:: - - list(sorted(l)) - - list(reversed(l)) - - ----- - - -.. _inserting-list-items: - -Inserting List Items -.................... - -.. grid:: 1 1 1 2 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - l.append(value) - - l.extend(values) - - l.insert(index, value) - - # Adding a list "in-place" mutates! - l += [value] - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - [*l, value] - - l + [value] - - l + values - - l[:index] + [value] + l[index:] - -Instead of mutating a list to add items to it, we need to create a new list which has -the items we want to append instead. There are several ways to do this for one or more -values however it's often simplest to use `"unpacking" -<https://www.python.org/dev/peps/pep-0448/>`__ with the ``*`` syntax. - -.. reactpy:: _examples/list_insert - - -.. _removing-list-items: - -Removing List Items -................... - -.. grid:: 1 1 1 2 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - del l[index] - - l.pop(index) - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - l[:index] + l[index + 1:] - -Unfortunately, the syntax for creating a copy of a list with one of its items removed is -not quite as clean. You must select the portion the list prior to the item which should -be removed (``l[:index]``) and the portion after the item (``l[index + 1:]``) and add -them together: - -.. reactpy:: _examples/list_remove - - -.. _replacing-list-items: - -Replacing List Items -.................... - -.. grid:: 1 1 1 2 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - l[index] = value - - l[start:end] = values - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - l[:index] + [value] + l[index + 1:] - - l[:start] + values + l[end + 1:] - -In a similar manner to :ref:`removing list items`, to replace an item in a list, you -must select the portion before and after the item in question. But this time, instead -of adding those two selections together, you must insert that values you want to replace -between them: - -.. reactpy:: _examples/list_replace - - -.. _re-ordering-list-items: - -Re-ordering List Items -...................... - -.. grid:: 1 1 1 2 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - l.sort() - - l.reverse() - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - list(sorted(l)) - - list(reversed(l)) - -There are many different ways that list items could be re-ordered, but two of the most -common are reversing or sorting items. Instead of calling the associated methods on a -list object, you should use the builtin functions :func:`sorted` and :func:`reversed` -and pass the resulting iterator into the :class:`list` constructor to create a sorted -or reversed copy of the given list: - -.. reactpy:: _examples/list_re_order - - -Working with Sets ------------------ - -Below are ways to update sets without mutating them: - -.. card:: Adding Items - :link: adding-set-items - :link-type: ref - - Avoid using item assignment, ``set.add`` or ``set.update``. Instead try the - strategies below: - - .. code-block:: - - s.union({value}) - - s.union(values) - -.. card:: Removing Items - :link: removing-set-items - :link-type: ref - - Avoid using item deletion or ``dict.pop``. Instead try the strategies below: - - .. code-block:: - - s.difference({value}) - - s.difference(values) - - s.intersection(values) - - ----- - - -.. _adding-set-items: - -Adding Set Items -................ - -.. grid:: 1 1 1 2 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - s.add(value) - s |= {value} # "in-place" operators mutate! - - s.update(values) - s |= values # "in-place" operators mutate! - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - s.union({value}) - s | {value} - - s.union(values) - s | values - -Sets have some nice ways for evolving them without requiring mutation. The binary -or operator ``|`` serves as a succinct way to compute the union of two sets. However, -you should be careful to not use an in-place assignment with this operator as that will -(counterintuitively) mutate the original set rather than creating a new one. - -.. reactpy:: _examples/set_update - - -.. _removing-set-items: - -Removing Set Items -.................. - -.. grid:: 1 1 1 2 - - .. grid-item-card:: :bdg-danger:`Avoid` - - .. code-block:: - - s.remove(value) - - s.difference_update(values) - s -= values # "in-place" operators mutate! - - s.symmetric_difference_update(values) - s ^= values # "in-place" operators mutate! - - s.intersection_update(values) - s &= values # "in-place" operators mutate! - - - .. grid-item-card:: :bdg-info:`Prefer` - - .. code-block:: - - s.difference({value}) - - s.difference(values) - s - values - - s.symmetric_difference(values) - s ^ values - - s.intersection(values) - s & values - -To remove items from sets you can use the various binary operators or their associated -methods to return new sets without mutating them. As before when :ref:`adding set items` -you need to avoid using the inline assignment operators since that will -(counterintuitively) mutate the original set rather than given you a new one: - -.. reactpy:: _examples/set_remove - - -Useful Packages ---------------- - -Under construction 🚧 - -https://pypi.org/project/pyrsistent/ diff --git a/docs/source/guides/adding-interactivity/index.rst b/docs/source/guides/adding-interactivity/index.rst deleted file mode 100644 index b2beb3a81..000000000 --- a/docs/source/guides/adding-interactivity/index.rst +++ /dev/null @@ -1,210 +0,0 @@ -Adding Interactivity -==================== - -.. toctree:: - :hidden: - - responding-to-events/index - components-with-state/index - state-as-a-snapshot/index - multiple-state-updates/index - dangers-of-mutability/index - - -.. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn - :color: info - :animate: fade-in - :open: - - .. grid:: 1 2 2 2 - - .. grid-item-card:: :octicon:`bell` Responding to Events - :link: responding-to-events/index - :link-type: doc - - Define event handlers and learn about the available event types they can be - bound to. - - .. grid-item-card:: :octicon:`package-dependencies` Components With State - :link: components-with-state/index - :link-type: doc - - Allow components to change what they display by saving and updating their - state. - - .. grid-item-card:: :octicon:`device-camera-video` State as a Snapshot - :link: state-as-a-snapshot/index - :link-type: doc - - Learn why state updates schedules a re-render, instead of being applied - immediately. - - .. grid-item-card:: :octicon:`versions` Multiple State Updates - :link: multiple-state-updates/index - :link-type: doc - - Learn how updates to a components state can be batched, or applied - incrementally. - - .. grid-item-card:: :octicon:`issue-opened` Dangers of Mutability - :link: dangers-of-mutability/index - :link-type: doc - - See the pitfalls of working with mutable data types and how to avoid them. - - -Section 1: Responding to Events -------------------------------- - -ReactPy lets you add event handlers to your parts of the interface. This means that you can -define synchronous or asynchronous functions that are triggered when a particular user -interaction occurs like clicking, hovering, of focusing on form inputs, and more. - -.. reactpy:: responding-to-events/_examples/button_prints_message - -It may feel weird to define a function within a function like this, but doing so allows -the ``handle_event`` function to access information from within the scope of the -component. That's important if you want to use any arguments that may have beend passed -your component in the handler. - -.. card:: - :link: responding-to-events/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Define event handlers and learn about the available event types they can be bound - to. - - -Section 2: Components with State --------------------------------- - -Components often need to change what’s on the screen as a result of an interaction. For -example, typing into the form should update the input field, clicking a “Comment” button -should bring up a text input field, clicking “Buy” should put a product in the shopping -cart. Components need to “remember” things like the current input value, the current -image, the shopping cart. In ReactPy, this kind of component-specific memory is created and -updated with a "hook" called ``use_state()`` that creates a **state variable** and -**state setter** respectively: - -.. reactpy:: components-with-state/_examples/adding_state_variable - -In ReactPy, ``use_state``, as well as any other function whose name starts with ``use``, is -called a "hook". These are special functions that should only be called while ReactPy is -:ref:`rendering <the rendering process>`. They let you "hook into" the different -capabilities of ReactPy's components of which ``use_state`` is just one (well get into the -other :ref:`later <managing state>`). - -.. card:: - :link: components-with-state/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Allow components to change what they display by saving and updating their state. - - -Section 3: State as a Snapshot ------------------------------- - -As we :ref:`learned earlier <Components with State>`, state setters behave a little -differently than you might expect at first glance. Instead of updating your current -handle on the setter's corresponding variable, it schedules a re-render of the component -which owns the state. - -.. code-block:: - - count, set_count = use_state(0) - print(count) # prints: 0 - set_count(count + 1) # schedule a re-render where count is 1 - print(count) # still prints: 0 - -This behavior of ReactPy means that each render of a component is like taking a snapshot of -the UI based on the component's state at that time. Treating state in this way can help -reduce subtle bugs. For instance, in the code below there's a simple chat app with a -message input and recipient selector. The catch is that the message actually gets sent 5 -seconds after the "Send" button is clicked. So what would happen if we changed the -recipient between the time the "Send" button was clicked and the moment the message is -actually sent? - -.. reactpy:: state-as-a-snapshot/_examples/print_chat_message - -As it turns out, changing the message recipient after pressing send does not change -where the message ultimately goes. However, one could imagine a bug where the recipient -of a message is determined at the time the message is sent rather than at the time the -"Send" button it clicked. Thus changing the recipient after pressing send would change -where the message got sent. - -In many cases, ReactPy avoids this class of bug entirely because it treats state as a -snapshot. - -.. card:: - :link: state-as-a-snapshot/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn why state updates schedules a re-render, instead of being applied immediately. - - -Section 4: Multiple State Updates ---------------------------------- - -As we saw in an earlier example, :ref:`setting state triggers renders`. In other words, -changes to state only take effect in the next render, not in the current one. Further, -changes to state are batched, calling a particular state setter 3 times won't trigger 3 -renders, it will only trigger 1. This means that multiple state assignments are batched -- so long as the event handler is synchronous (i.e. the event handler is not an -``async`` function), ReactPy waits until all the code in an event handler has run before -processing state and starting the next render: - -.. reactpy:: multiple-state-updates/_examples/set_color_3_times - -Sometimes though, you need to update a state variable more than once before the next -render. In these cases, instead of having updates batched, you instead want them to be -applied incrementally. That is, the next update can be made to depend on the prior one. -To accomplish this, instead of passing the next state value directly (e.g. -``set_state(new_state)``), we may pass an **"updater function"** of the form -``compute_new_state(old_state)`` to the state setter (e.g. -``set_state(compute_new_state)``): - -.. reactpy:: multiple-state-updates/_examples/set_state_function - -.. card:: - :link: multiple-state-updates/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn how updates to a components state can be batched, or applied incrementally. - - -Section 5: Dangers of Mutability --------------------------------- - -While state can hold any type of value, you should be careful to avoid directly -modifying objects that you declare as state with ReactPy. In other words, you must not -:ref:`"mutate" <What is a Mutation>` values which are held as state. Rather, to change -these values you should use new ones or create copies. - -This is because ReactPy does not understand that when a value is mutated, it may have -changed. As a result, mutating values will not trigger re-renders. Thus, you must be -careful to avoid mutation whenever you want ReactPy to re-render a component. For example, -instead of mutating dictionaries to update their items you should instead create a -copy that contains the desired changes: - -.. reactpy:: dangers-of-mutability/_examples/dict_update - -.. card:: - :link: dangers-of-mutability/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - See the pitfalls of working with mutable data types and how to avoid them. diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py deleted file mode 100644 index e53c5b1ad..000000000 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py +++ /dev/null @@ -1,20 +0,0 @@ -import asyncio - -from reactpy import component, html, run, use_state - - -@component -def Counter(): - number, set_number = use_state(0) - - async def handle_click(event): - await asyncio.sleep(3) - set_number(lambda old_number: old_number + 1) - - return html.div( - html.h1(number), - html.button({"on_click": handle_click}, "Increment"), - ) - - -run(Counter) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py deleted file mode 100644 index bb64724f1..000000000 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py +++ /dev/null @@ -1,20 +0,0 @@ -import asyncio - -from reactpy import component, html, run, use_state - - -@component -def Counter(): - number, set_number = use_state(0) - - async def handle_click(event): - await asyncio.sleep(3) - set_number(number + 1) - - return html.div( - html.h1(number), - html.button({"on_click": handle_click}, "Increment"), - ) - - -run(Counter) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py deleted file mode 100644 index 59d7d0f20..000000000 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py +++ /dev/null @@ -1,27 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def ColorButton(): - color, set_color = use_state("gray") - - def handle_click(event): - set_color("orange") - set_color("pink") - set_color("blue") - - def handle_reset(event): - set_color("gray") - - return html.div( - html.button( - {"on_click": handle_click, "style": {"background_color": color}}, - "Set Color", - ), - html.button( - {"on_click": handle_reset, "style": {"background_color": color}}, "Reset" - ), - ) - - -run(ColorButton) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py deleted file mode 100644 index 56bbe80e3..000000000 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py +++ /dev/null @@ -1,24 +0,0 @@ -from reactpy import component, html, run, use_state - - -def increment(old_number): - new_number = old_number + 1 - return new_number - - -@component -def Counter(): - number, set_number = use_state(0) - - def handle_click(event): - set_number(increment) - set_number(increment) - set_number(increment) - - return html.div( - html.h1(number), - html.button({"on_click": handle_click}, "Increment"), - ) - - -run(Counter) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst b/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst deleted file mode 100644 index bc3245d7e..000000000 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/index.rst +++ /dev/null @@ -1,109 +0,0 @@ -Multiple State Updates -====================== - -Setting a state variable will queue another render. But sometimes you might want to -perform multiple operations on the value before queueing the next render. To do this, it -helps to understand how React batches state updates. - - -Batched Updates ---------------- - -As we learned :ref:`previously <state as a snapshot>`, state variables remain fixed -inside each render as if state were a snapshot taken at the beginning of each render. -This is why, in the example below, even though it might seem like clicking the -"Increment" button would cause the ``number`` to increase by ``3``, it only does by -``1``: - -.. reactpy:: ../state-as-a-snapshot/_examples/set_counter_3_times - -The reason this happens is because, so long as the event handler is synchronous (i.e. -the event handler is not an ``async`` function), ReactPy waits until all the code in an -event handler has run before processing state and starting the next render. Thus, it's -the last call to a given state setter that matters. In the example below, even though we -set the color of the button to ``"orange"`` and then ``"pink"`` before ``"blue"``, -the color does not quickly flash orange and pink before blue - it always remains blue: - -.. reactpy:: _examples/set_color_3_times - -This behavior let's you make multiple state changes without triggering unnecessary -renders or renders with inconsistent state where only some of the variables have been -updated. With that said, it also means that the UI won't change until after synchronous -handlers have finished running. - -.. note:: - - For asynchronous event handlers, ReactPy will not render until you ``await`` something. - As we saw in :ref:`prior examples <State And Delayed Reactions>`, if you introduce - an asynchronous delay to an event handler after changing state, renders may take - place before the remainder of the event handler completes. However, state variables - within handlers, even async ones, always remains static. - -This behavior of ReactPy to "batch" state changes that take place inside a single event -handler, do not extend across event handlers. In other words, distinct events will -always produce distinct renders. To give an example, if clicking a button increments a -counter by one, no matter how fast the user clicks, the view will never jump from 1 to 3 -- it will always display 1, then 2, and then 3. - - -Incremental Updates -------------------- - -While it's uncommon, you need to update a state variable more than once before the next -render. In these cases, instead of having updates batched, you instead want them to be -applied incrementally. That is, the next update can be made to depend on the prior one. -For example, what it we wanted to make it so that, in our ``Counter`` example :ref:`from -before <Batched Updates>`, each call to ``set_number`` did in fact increment -``number`` by one causing the view to display ``0``, then ``3``, then ``6``, and so on? - -To accomplish this, instead of passing the next state value as in ``set_number(number + -1)``, we may pass an **"updater function"** to ``set_number`` that computes the next -state based on the previous state. This would look like ``set_number(lambda number: -number + 1)``. In other words we need a function of the form: - -.. code-block:: - - def compute_new_state(old_state): - ... - return new_state - -In our case, ``new_state = old_state + 1``. So we might define: - -.. code-block:: - - def increment(old_number): - new_number = old_number + 1 - return new_number - -Which we can use to replace ``set_number(number + 1)`` with ``set_number(increment)``: - -.. reactpy:: _examples/set_state_function - -The way to think about how ReactPy runs though this series of ``set_state(increment)`` -calls is to imagine that each one updates the internally managed state with its return -value, then that return value is being passed to the next updater function. Ultimately, -this is functionally equivalent to the following: - -.. code-block:: - - set_number(increment(increment(increment(number)))) - -So why might you want to do this? Why not just compute ``set_number(number + 3)`` from -the start? The easiest way to explain the use case is with an example. Imagine that we -introduced a delay before ``set_number(number + 1)``. What would happen if we clicked -the "Increment" button more than once before the delay in the first triggered event -completed? - -.. reactpy:: _examples/delay_before_set_count - -From an :ref:`earlier lesson <State And Delayed Reactions>`, we learned that introducing -delays do not change the fact that state variables do not change until the next render. -As a result, despite clicking many times before the delay completes, the ``number`` only -increments by one. To solve this we can use updater functions: - -.. reactpy:: _examples/delay_before_count_updater - -Now when you click the "Increment" button, each click, though delayed, corresponds to -``number`` being increased. This is because the ``old_number`` in the updater function -uses the value which was assigned by the last call to ``set_number`` rather than relying -in the static ``number`` state variable. diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py deleted file mode 100644 index 82826c50d..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py +++ /dev/null @@ -1,21 +0,0 @@ -import json - -import reactpy - - -@reactpy.component -def PlayDinosaurSound(): - event, set_event = reactpy.hooks.use_state(None) - return reactpy.html.div( - reactpy.html.audio( - { - "controls": True, - "on_time_update": lambda e: set_event(e), - "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - } - ), - reactpy.html.pre(json.dumps(event, indent=2)), - ) - - -reactpy.run(PlayDinosaurSound) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py deleted file mode 100644 index 992641e00..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py +++ /dev/null @@ -1,23 +0,0 @@ -import asyncio - -from reactpy import component, html, run - - -@component -def ButtonWithDelay(message, delay): - async def handle_event(event): - await asyncio.sleep(delay) - print(message) - - return html.button({"on_click": handle_event}, message) - - -@component -def App(): - return html.div( - ButtonWithDelay("print 3 seconds later", delay=3), - ButtonWithDelay("print immediately", delay=0), - ) - - -run(App) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_does_nothing.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_does_nothing.py deleted file mode 100644 index ea8313263..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_does_nothing.py +++ /dev/null @@ -1,9 +0,0 @@ -from reactpy import component, html, run - - -@component -def Button(): - return html.button("I don't do anything yet") - - -run(Button) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py deleted file mode 100644 index e5276bef3..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py +++ /dev/null @@ -1,33 +0,0 @@ -from reactpy import component, html, run - - -@component -def Button(display_text, on_click): - return html.button({"on_click": on_click}, display_text) - - -@component -def PlayButton(movie_name): - def handle_click(event): - print(f"Playing {movie_name}") - - return Button(f"Play {movie_name}", on_click=handle_click) - - -@component -def FastForwardButton(): - def handle_click(event): - print("Skipping ahead") - - return Button("Fast forward", on_click=handle_click) - - -@component -def App(): - return html.div( - PlayButton("Buena Vista Social Club"), - FastForwardButton(), - ) - - -run(App) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py deleted file mode 100644 index 38638db4b..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py +++ /dev/null @@ -1,12 +0,0 @@ -from reactpy import component, html, run - - -@component -def Button(): - def handle_event(event): - print(event) - - return html.button({"on_click": handle_event}, "Click me!") - - -run(Button) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py deleted file mode 100644 index 56118a57f..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py +++ /dev/null @@ -1,20 +0,0 @@ -from reactpy import component, html, run - - -@component -def PrintButton(display_text, message_text): - def handle_event(event): - print(message_text) - - return html.button({"on_click": handle_event}, display_text) - - -@component -def App(): - return html.div( - PrintButton("Play", "Playing"), - PrintButton("Pause", "Paused"), - ) - - -run(App) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py deleted file mode 100644 index d3f0941bd..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py +++ /dev/null @@ -1,18 +0,0 @@ -from reactpy import component, event, html, run - - -@component -def DoNotChangePages(): - return html.div( - html.p("Normally clicking this link would take you to a new page"), - html.a( - { - "on_click": event(lambda event: None, prevent_default=True), - "href": "https://google.com", - }, - "https://google.com", - ), - ) - - -run(DoNotChangePages) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py deleted file mode 100644 index 41c575042..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py +++ /dev/null @@ -1,42 +0,0 @@ -from reactpy import component, event, hooks, html, run - - -@component -def DivInDiv(): - stop_propagatation, set_stop_propagatation = hooks.use_state(True) - inner_count, set_inner_count = hooks.use_state(0) - outer_count, set_outer_count = hooks.use_state(0) - - div_in_div = html.div( - { - "on_click": lambda event: set_outer_count(outer_count + 1), - "style": {"height": "100px", "width": "100px", "background_color": "red"}, - }, - html.div( - { - "on_click": event( - lambda event: set_inner_count(inner_count + 1), - stop_propagation=stop_propagatation, - ), - "style": { - "height": "50px", - "width": "50px", - "background_color": "blue", - }, - } - ), - ) - - return html.div( - html.button( - {"on_click": lambda event: set_stop_propagatation(not stop_propagatation)}, - "Toggle Propagation", - ), - html.pre(f"Will propagate: {not stop_propagatation}"), - html.pre(f"Inner click count: {inner_count}"), - html.pre(f"Outer click count: {outer_count}"), - div_in_div, - ) - - -run(DivInDiv) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/index.rst b/docs/source/guides/adding-interactivity/responding-to-events/index.rst deleted file mode 100644 index 583a4a4b7..000000000 --- a/docs/source/guides/adding-interactivity/responding-to-events/index.rst +++ /dev/null @@ -1,144 +0,0 @@ -Responding to Events -==================== - -ReactPy lets you add event handlers to your parts of the interface. These events handlers -are functions which can be assigned to a part of a UI such that, when a user iteracts -with the interface, those functions get triggered. Examples of interaction include -clicking, hovering, of focusing on form inputs, and more. - - -Adding Event Handlers ---------------------- - -To start out we'll just display a button that, for the moment, doesn't do anything: - -.. reactpy:: _examples/button_does_nothing - -To add an event handler to this button we'll do three things: - -1. Declare a function called ``handle_event(event)`` inside the body of our ``Button`` component -2. Add logic to ``handle_event`` that will print the ``event`` it receives to the console. -3. Add an ``"onClick": handle_event`` attribute to the ``<button>`` element. - -.. reactpy:: _examples/button_prints_event - -.. note:: - - Normally print statements will only be displayed in the terminal where you launched - ReactPy. - -It may feel weird to define a function within a function like this, but doing so allows -the ``handle_event`` function to access information from within the scope of the -component. That's important if you want to use any arguments that may have beend passed -your component in the handler: - -.. reactpy:: _examples/button_prints_message - -With all that said, since our ``handle_event`` function isn't doing that much work, if -we wanted to streamline our component definition, we could pass in our event handler as a -lambda: - -.. code-block:: - - html.button({"onClick": lambda event: print(message_text)}, "Click me!") - - -Supported Event Types ---------------------- - -Since ReactPy's event information comes from React, most the the information (:ref:`with -some exceptions <event data Serialization>`) about how React handles events translates -directly to ReactPy. Follow the links below to learn about each category of event: - -- :ref:`Clipboard Events` -- :ref:`Composition Events` -- :ref:`Keyboard Events` -- :ref:`Focus Events` -- :ref:`Form Events` -- :ref:`Generic Events` -- :ref:`Mouse Events` -- :ref:`Pointer Events` -- :ref:`Selection Events` -- :ref:`Touch Events` -- :ref:`UI Events` -- :ref:`Wheel Events` -- :ref:`Media Events` -- :ref:`Image Events` -- :ref:`Animation Events` -- :ref:`Transition Events` -- :ref:`Other Events` - - -Passing Handlers to Components ------------------------------- - -A common pattern when factoring out common logic is to pass event handlers into a more -generic component definition. This allows the component to focus on the things which are -common while still giving its usages customizablity. Consider the case below where we -want to create a generic ``Button`` component that can be used for a variety of purpose: - -.. reactpy:: _examples/button_handler_as_arg - - -.. _Async Event Handler: - -Async Event Handlers --------------------- - -Sometimes event handlers need to execute asynchronous tasks when they are triggered. -Behind the scenes, ReactPy is running an :mod:`asyncio` event loop for just this purpose. -By defining your event handler as an asynchronous function instead of a normal -synchronous one. In the layout below we sleep for several seconds before printing out a -message in the first button. However, because the event handler is asynchronous, the -handler for the second button is still able to respond: - -.. reactpy:: _examples/button_async_handlers - - -Event Data Serialization ------------------------- - -Not all event data is serialized. The most notable example of this is the lack of a -``target`` key in the dictionary sent back to the handler. Instead, data which is not -inherently JSON serializable must be treated on a case-by-case basis. A simple case -to demonstrate this is the ``currentTime`` attribute of ``audio`` and ``video`` -elements. Normally this would be accessible via ``event.target.currentTime``, but here -it's simply passed in under the key ``currentTime``: - -.. reactpy:: _examples/audio_player - - -Client-side Event Behavior --------------------------- - -Because ReactPy operates server-side, there are inevitable limitations that prevent it from -achieving perfect parity with all the behaviors of React. With that said, any feature -that cannot be achieved in Python with ReactPy, can be done by creating -:ref:`Custom Javascript Components`. - - -Preventing Default Event Actions -................................ - -Instead of calling an ``event.preventDefault()`` method as you would do in React, you -must declare whether to prevent default behavior ahead of time. This can be accomplished -using the :func:`~reactpy.core.events.event` decorator and setting ``prevent_default``. For -example, we can stop a link from going to the specified URL: - -.. reactpy:: _examples/prevent_default_event_actions - -Unfortunately this means you cannot conditionally prevent default behavior in response -to event data without writing :ref:`Custom Javascript Components`. - - -Stop Event Propagation -...................... - -Similarly to :ref:`preventing default behavior <Preventing Default Event Actions>`, you -can use the :func:`~reactpy.core.events.event` decorator to prevent events originating in a -child element from propagating to parent elements by setting ``stop_propagation``. In -the example below we place a red ``div`` inside a parent blue ``div``. When propagation -is turned on, clicking the red element will cause the handler for the outer blue one to -trigger. Conversely, when it's off, only the handler for the red element will trigger. - -.. reactpy:: _examples/stop_event_propagation diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py deleted file mode 100644 index b6295b09f..000000000 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio - -from reactpy import component, html, run, use_state - - -@component -def Counter(): - number, set_number = use_state(0) - - async def handle_click(event): - set_number(number + 5) - print("about to print...") - await asyncio.sleep(3) - print(number) - - return html.div( - html.h1(number), - html.button({"on_click": handle_click}, "Increment"), - ) - - -run(Counter) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py deleted file mode 100644 index ecbad9381..000000000 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py +++ /dev/null @@ -1,44 +0,0 @@ -import asyncio - -from reactpy import component, event, html, run, use_state - - -@component -def App(): - recipient, set_recipient = use_state("Alice") - message, set_message = use_state("") - - @event(prevent_default=True) - async def handle_submit(event): - set_message("") - print("About to send message...") - await asyncio.sleep(5) - print(f"Sent '{message}' to {recipient}") - - return html.form( - {"on_submit": handle_submit, "style": {"display": "inline-grid"}}, - html.label( - {}, - "To: ", - html.select( - { - "value": recipient, - "on_change": lambda event: set_recipient(event["target"]["value"]), - }, - html.option({"value": "Alice"}, "Alice"), - html.option({"value": "Bob"}, "Bob"), - ), - ), - html.input( - { - "type": "text", - "placeholder": "Your message...", - "value": message, - "on_change": lambda event: set_message(event["target"]["value"]), - } - ), - html.button({"type": "submit"}, "Send"), - ) - - -run(App) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py deleted file mode 100644 index 40ee78259..000000000 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py +++ /dev/null @@ -1,18 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def Counter(): - number, set_number = use_state(0) - - def handle_click(event): - set_number(number + 5) - print(number) - - return html.div( - html.h1(number), - html.button({"on_click": handle_click}, "Increment"), - ) - - -run(Counter) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py deleted file mode 100644 index 4702a7464..000000000 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py +++ /dev/null @@ -1,35 +0,0 @@ -from reactpy import component, event, html, run, use_state - - -@component -def App(): - is_sent, set_is_sent = use_state(False) - message, set_message = use_state("") - - if is_sent: - return html.div( - html.h1("Message sent!"), - html.button( - {"on_click": lambda event: set_is_sent(False)}, "Send new message?" - ), - ) - - @event(prevent_default=True) - def handle_submit(event): - set_message("") - set_is_sent(True) - - return html.form( - {"on_submit": handle_submit, "style": {"display": "inline-grid"}}, - html.textarea( - { - "placeholder": "Your message here...", - "value": message, - "on_change": lambda event: set_message(event["target"]["value"]), - } - ), - html.button({"type": "submit"}, "Send"), - ) - - -run(App) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py deleted file mode 100644 index fe97351af..000000000 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py +++ /dev/null @@ -1,19 +0,0 @@ -from reactpy import component, html, run, use_state - - -@component -def Counter(): - number, set_number = use_state(0) - - def handle_click(event): - set_number(number + 1) - set_number(number + 1) - set_number(number + 1) - - return html.div( - html.h1(number), - html.button({"on_click": handle_click}, "Increment"), - ) - - -run(Counter) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_static/direct-state-change.png b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_static/direct-state-change.png deleted file mode 100644 index cfcd9c87f..000000000 Binary files a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_static/direct-state-change.png and /dev/null differ diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_static/reactpy-state-change.png b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_static/reactpy-state-change.png deleted file mode 100644 index b36b0514a..000000000 Binary files a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_static/reactpy-state-change.png and /dev/null differ diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/index.rst b/docs/source/guides/adding-interactivity/state-as-a-snapshot/index.rst deleted file mode 100644 index a677a3e68..000000000 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/index.rst +++ /dev/null @@ -1,158 +0,0 @@ -State as a Snapshot -=================== - -When you watch the user interfaces you build change as you interact with them, it's easy -to imagining that they do so because there's some bit of code that modifies the relevant -parts of the view directly. As an illustration, you may think that when a user clicks a -"Send" button, there's code which reaches into the view and adds some text saying -"Message sent!": - -.. image:: _static/direct-state-change.png - -ReactPy works a bit differently though - user interactions cause event handlers to -:ref:`"set state" <Introduction to use_state>` triggering ReactPy to re-render a new -version of the view rather then mutating the existing one. - -.. image:: _static/reactpy-state-change.png - -Given this, when ReactPy "renders" something, it's as if ReactPy has taken a snapshot of the -UI where all the event handlers, local variables and the view itself were calculated -using what state was present at the time of that render. Then, when user interactions -trigger state setters, ReactPy is made away of the newly set state and schedules a -re-render. When this subsequent renders occurs it performs all the same calculations as -before, but with this new state. - -As we've :ref:`already seen <When Variables Aren't Enough>`, state variables are not -like normal variables. Instead, they live outside your components and are managed by -ReactPy. When a component is rendered, ReactPy provides the component a snapshot of the state -in that exact moment. As a result, the view returned by that component is itself a -snapshot of the UI at that time. - - -Setting State Triggers Renders ------------------------------- - -Setting state does not impact the current render, instead it schedules a re-render. It's -only in this subsequent render that changes to state take effect. As a result, setting -state more than once in the context of the same render will not cause those changes to -compound. This makes it easier to reason about how your UI will react to user -interactions because state does not change until the next render. - -Let's experiment with this behaviors of state to see why we should think about it with -respect to these "snapshots" in time. Take a look at the example below and try to guess -how it will behave. **What will the count be after you click the "Increment" button?** - -.. reactpy:: _examples/set_counter_3_times - -Despite the fact that we called ``set_count(count + 1)`` three times, the count only -increments by ``1``! This is perhaps a surprising result, but let's break what's -happening inside the event handler to see why this is happening: - -.. code-block:: - - set_count(count + 1) - set_count(count + 1) - set_count(count + 1) - -On the initial render of your ``Counter`` the ``number`` variable is ``0``. Because we -know that state variables do not change until the next render we ought to be able to -substitute ``number`` with ``0`` everywhere it's referenced within the component until -then. That includes the event handler too we should be able to rewrite the three lines -above as: - -.. code-block:: - - set_count(0 + 1) - set_count(0 + 1) - set_count(0 + 1) - -Even though, we called ``set_count`` three times with what might have seemed like -different values, every time we were actually just doing ``set_count(1)`` on each call. -Only after the event handler returns will ReactPy actually perform the next render where -count is ``1``. When it does, ``number`` will be ``1`` and we'll be able to perform the -same substitution as before to see what the next number will be after we click -"Increment": - -.. code-block:: - - set_count(1 + 1) - set_count(1 + 1) - set_count(1 + 1) - - -State And Delayed Reactions ---------------------------- - -Given what we :ref:`learned above <setting state triggers renders>`, we ought to be able -to reason about what should happen in the example below. What will be printed when the -"Increment" button is clicked? - -.. reactpy:: _examples/print_count_after_set - -If we use the same substitution trick we saw before, we can rewrite these lines: - -.. code-block:: - - set_number(number + 5) - print(number) - -Using the value of ``number`` in the initial render which is ``0``: - -.. code-block:: - - set_number(0 + 5) - print(0) - -Thus when we click the button we should expect that the next render will show ``5``, but -we will ``print`` the number ``0`` instead. The next time we click the view will show -``10`` and the printout will be ``5``. In this sense the print statement, because it -lives within the prior snapshot, trails what is displayed in the next render. - -What if we slightly modify this example, by introducing a delay between when we call -``set_number`` and when we print? Will this behavior remain the same? To add this delay -we'll use an :ref:`async event handler` and :func:`~asyncio.sleep` for some time: - -.. reactpy:: _examples/delayed_print_after_set - -Even though the render completed before the print statement took place, the behavior -remained the same! Despite the fact that the next render took place before the print -statement did, the print statement still relies on the state snapshot from the initial -render. Thus we can continue to use our substitution trick to analyze what's happening: - -.. code-block:: - - set_number(0 + 5) - print("about to print...") - await asyncio.sleep(3) - print(0) - -This property of state, that it remains static within the context of particular render, -while unintuitive at first, is actually an important tool for preventing subtle bugs. -Let's consider the example below where there's a form that sends a message with a 5 -second delay. Imagine a scenario where the user: - -1. Presses the "Send" button with the message "Hello" where "Alice" is the recipient. -2. Then, before the five-second delay ends, the user changes the "To" field to "Bob". - -The first question to ask is "What should happen?" In this case, the user's expectation -is that after they press "Send", changing the recipient, even if the message has not -been sent yet, should not impact where the message is ultimately sent. We then need to -ask what actually happens. Will it print “You said Hello to Alice” or “You said Hello to -Bob”? - -.. reactpy:: _examples/print_chat_message - -As it turns out, the code above matches the user's expectation. This is because ReactPy -keeps the state values fixed within the event handlers defined during a particular -render. As a result, you don't need to worry about whether state has changed while -code in an event handler is running. - -.. card:: - :link: ../multiple-state-updates/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - What if you wanted to read the latest state values before the next render? You’ll - want to use a state updater function, covered on the next page! diff --git a/docs/source/guides/creating-interfaces/html-with-reactpy/index.rst b/docs/source/guides/creating-interfaces/html-with-reactpy/index.rst deleted file mode 100644 index 4a8ba4957..000000000 --- a/docs/source/guides/creating-interfaces/html-with-reactpy/index.rst +++ /dev/null @@ -1,131 +0,0 @@ -HTML With ReactPy -================= - -In a typical Python-based web application the responsibility of defining the view along -with its backing data and logic are distributed between a client and server -respectively. With ReactPy, both these tasks are centralized in a single place. This is -done by allowing HTML interfaces to be constructed in Python. Take a look at the two -code examples below. The first one shows how to make a basic title and todo list using -standard HTML, the second uses ReactPy in Python, and below is a view of what the HTML -would look like if displayed: - -.. grid:: 1 1 2 2 - :margin: 0 - :padding: 0 - - .. grid-item:: - - .. code-block:: html - - <h1>My Todo List</h1> - <ul> - <li>Build a cool new app</li> - <li>Share it with the world!</li> - </ul> - - .. grid-item:: - - .. testcode:: - - from reactpy import html - - html.h1("My Todo List") - html.ul( - html.li("Build a cool new app"), - html.li("Share it with the world!"), - ) - - .. grid-item-card:: - :columns: 12 - - .. raw:: html - - <div style="width: 50%; margin: auto;"> - <h2 style="margin-top: 0px !important;">My Todo List</h2> - <ul> - <li>Build a cool new app</li> - <li>Share it with the world!</li> - </ul> - </div> - -What this shows is that you can recreate the same HTML layouts with ReactPy using functions -from the :mod:`reactpy.html` module. These function share the same names as their -corresponding HTML tags. For instance, the ``<h1/>`` element above has a similarly named -:func:`~reactpy.html.h1` function. With that said, while the code above looks similar, it's -not very useful because we haven't captured the results from these function calls in a -variable. To do this we need to wrap up the layout above into a single -:func:`~reactpy.html.div` and assign it to a variable: - -.. testcode:: - - layout = html.div( - html.h1("My Todo List"), - html.ul( - html.li("Build a cool new app"), - html.li("Share it with the world!"), - ), - ) - - -Adding HTML Attributes ----------------------- - -That's all well and good, but there's more to HTML than just text. What if we wanted to -display an image? In HTMl we'd use the ``<img>`` element and add attributes to it order -to specify a URL to its ``src`` and use some ``style`` to modify and position it: - -.. code-block:: html - - <img - src="https://picsum.photos/id/237/500/300" - class="img-fluid" - style="width: 50%; margin-left: 25%;" - alt="Billie Holiday" - tabindex="0" - /> - -In ReactPy we add these attributes to elements using a dictionary: - -.. testcode:: - - html.img( - { - "src": "https://picsum.photos/id/237/500/300", - "class_name": "img-fluid", - "style": {"width": "50%", "margin_left": "25%"}, - "alt": "Billie Holiday", - } - ) - -.. raw:: html - - <!-- no tabindex since that would ruin accessibility of the page --> - <img - src="https://picsum.photos/id/237/500/300" - class="img-fluid" - style="width: 50%; margin-left: 25%;" - alt="Billie Holiday" - /> - -There are some notable differences. First, all names in ReactPy use ``snake_case`` instead -of dash-separated words. For example, ``tabindex`` and ``margin-left`` become -``tab_index`` and ``margin_left`` respectively. Second, instead of using a string to -specify the ``style`` attribute, we use a dictionary to describe the CSS properties we -want to apply to an element. This is done to avoid having to escape quotes and other -characters in the string. Finally, the ``class`` attribute is renamed to ``class_name`` -to avoid conflicting with the ``class`` keyword in Python. - -For full list of supported attributes and differences from HTML, see the -:ref:`HTML Attributes` reference. - ----------- - - -.. card:: - :link: /guides/understanding-reactpy/representing-html - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Dive into the data structures ReactPy uses to represent HTML diff --git a/docs/source/guides/creating-interfaces/index.rst b/docs/source/guides/creating-interfaces/index.rst deleted file mode 100644 index 78466eaef..000000000 --- a/docs/source/guides/creating-interfaces/index.rst +++ /dev/null @@ -1,128 +0,0 @@ -Creating Interfaces -=================== - -.. toctree:: - :hidden: - - html-with-reactpy/index - your-first-components/index - rendering-data/index - -.. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn - :color: info - :animate: fade-in - :open: - - .. grid:: 1 2 2 2 - - .. grid-item-card:: :octicon:`code-square` HTML with ReactPy - :link: html-with-reactpy/index - :link-type: doc - - Construct HTML layouts from the basic units of user interface functionality. - - .. grid-item-card:: :octicon:`package` Your First Components - :link: your-first-components/index - :link-type: doc - - Define reusable building blocks that it easier to construct complex - interfaces. - - .. grid-item-card:: :octicon:`database` Rendering Data - :link: rendering-data/index - :link-type: doc - - Use data to organize and render HTML elements and components. - -ReactPy is a Python package for making user interfaces (UI). These interfaces are built -from small elements of functionality like buttons text and images. ReactPy allows you to -combine these elements into reusable, nestable :ref:`"components" <your first -components>`. In the sections that follow you'll learn how these UI elements are created -and organized into components. Then, you'll use components to customize and -conditionally display more complex UIs. - - -Section 1: HTML with ReactPy ----------------------------- - -In a typical Python-base web application the responsibility of defining the view along -with its backing data and logic are distributed between a client and server -respectively. With ReactPy, both these tasks are centralized in a single place. The most -foundational pilar of this capability is formed by allowing HTML interfaces to be -constructed in Python. Let's consider the HTML sample below: - -.. code-block:: html - - <h1>My Todo List</h1> - <ul> - <li>Build a cool new app</li> - <li>Share it with the world!</li> - </ul> - -To recreate the same thing in ReactPy you would write: - -.. code-block:: - - from reactpy import html - - html.div( - html.h1("My Todo List"), - html.ul( - html.li("Design a cool new app"), - html.li("Build it"), - html.li("Share it with the world!"), - ) - ) - -.. card:: - :link: html-with-reactpy/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Construct HTML layouts from the basic units of user interface functionality. - - -Section 2: Your First Components --------------------------------- - -The next building block in our journey with ReactPy are components. At their core, -components are just a normal Python functions that return :ref:`HTML <HTML with ReactPy>`. -The one special thing about them that we'll concern ourselves with now, is that to -create them we need to add an ``@component`` `decorator -<https://realpython.com/primer-on-python-decorators/>`__. To see what this looks like in -practice we'll quickly make a ``Photo`` component: - -.. reactpy:: your-first-components/_examples/simple_photo - -.. card:: - :link: your-first-components/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Define reusable building blocks that it easier to construct complex interfaces. - - -Section 3: Rendering Data -------------------------- - -The last pillar of knowledge you need before you can start making :ref:`interactive -interfaces <adding interactivity>` is the ability to render sections of the UI given a -collection of data. This will require you to understand how elements which are derived -from data in this way must be organized with :ref:`"keys" <Organizing Items With Keys>`. -One case where we might want to do this is if items in a todo list come from a list of -data that we want to sort and filter: - -.. reactpy:: rendering-data/_examples/todo_list_with_keys - -.. card:: - :link: rendering-data/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Use data to organize and render HTML elements and components. diff --git a/docs/source/guides/creating-interfaces/rendering-data/_examples/sorted_and_filtered_todo_list.py b/docs/source/guides/creating-interfaces/rendering-data/_examples/sorted_and_filtered_todo_list.py deleted file mode 100644 index 8be2b5f30..000000000 --- a/docs/source/guides/creating-interfaces/rendering-data/_examples/sorted_and_filtered_todo_list.py +++ /dev/null @@ -1,32 +0,0 @@ -from reactpy import component, html, run - - -@component -def DataList(items, filter_by_priority=None, sort_by_priority=False): - if filter_by_priority is not None: - items = [i for i in items if i["priority"] <= filter_by_priority] - if sort_by_priority: - items = sorted(items, key=lambda i: i["priority"]) - list_item_elements = [html.li(i["text"]) for i in items] - return html.ul(list_item_elements) - - -@component -def TodoList(): - tasks = [ - {"text": "Make breakfast", "priority": 0}, - {"text": "Feed the dog", "priority": 0}, - {"text": "Do laundry", "priority": 2}, - {"text": "Go on a run", "priority": 1}, - {"text": "Clean the house", "priority": 2}, - {"text": "Go to the grocery store", "priority": 2}, - {"text": "Do some coding", "priority": 1}, - {"text": "Read a book", "priority": 1}, - ] - return html.section( - html.h1("My Todo List"), - DataList(tasks, filter_by_priority=1, sort_by_priority=True), - ) - - -run(TodoList) diff --git a/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_from_list.py b/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_from_list.py deleted file mode 100644 index 85ba1d79d..000000000 --- a/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_from_list.py +++ /dev/null @@ -1,28 +0,0 @@ -from reactpy import component, html, run - - -@component -def DataList(items): - list_item_elements = [html.li(text) for text in items] - return html.ul(list_item_elements) - - -@component -def TodoList(): - tasks = [ - "Make breakfast (important)", - "Feed the dog (important)", - "Do laundry", - "Go on a run (important)", - "Clean the house", - "Go to the grocery store", - "Do some coding", - "Read a book (important)", - ] - return html.section( - html.h1("My Todo List"), - DataList(tasks), - ) - - -run(TodoList) diff --git a/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py b/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py deleted file mode 100644 index 8afd2ae55..000000000 --- a/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py +++ /dev/null @@ -1,32 +0,0 @@ -from reactpy import component, html, run - - -@component -def DataList(items, filter_by_priority=None, sort_by_priority=False): - if filter_by_priority is not None: - items = [i for i in items if i["priority"] <= filter_by_priority] - if sort_by_priority: - items = sorted(items, key=lambda i: i["priority"]) - list_item_elements = [html.li({"key": i["id"]}, i["text"]) for i in items] - return html.ul(list_item_elements) - - -@component -def TodoList(): - tasks = [ - {"id": 0, "text": "Make breakfast", "priority": 0}, - {"id": 1, "text": "Feed the dog", "priority": 0}, - {"id": 2, "text": "Do laundry", "priority": 2}, - {"id": 3, "text": "Go on a run", "priority": 1}, - {"id": 4, "text": "Clean the house", "priority": 2}, - {"id": 5, "text": "Go to the grocery store", "priority": 2}, - {"id": 6, "text": "Do some coding", "priority": 1}, - {"id": 7, "text": "Read a book", "priority": 1}, - ] - return html.section( - html.h1("My Todo List"), - DataList(tasks, filter_by_priority=1, sort_by_priority=True), - ) - - -run(TodoList) diff --git a/docs/source/guides/creating-interfaces/rendering-data/index.rst b/docs/source/guides/creating-interfaces/rendering-data/index.rst deleted file mode 100644 index 8b11ac439..000000000 --- a/docs/source/guides/creating-interfaces/rendering-data/index.rst +++ /dev/null @@ -1,297 +0,0 @@ -Rendering Data -============== - -Frequently you need to construct a number of similar components from a collection of -data. Let's imagine that we want to create a todo list that can be ordered and filtered -on the priority of each item in the list. To start, we'll take a look at the kind of -view we'd like to display: - -.. code-block:: html - - <ul> - <li>Make breakfast (important)</li> - <li>Feed the dog (important)</li> - <li>Do laundry</li> - <li>Go on a run (important)</li> - <li>Clean the house</li> - <li>Go to the grocery store</li> - <li>Do some coding</li> - <li>Read a book (important)</li> - </ul> - -Based on this, our next step in achieving our goal is to break this view down into the -underlying data that we'd want to use to represent it. The most straightforward way to -do this would be to just put the text of each ``<li>`` into a list: - -.. testcode:: - - tasks = [ - "Make breakfast (important)", - "Feed the dog (important)", - "Do laundry", - "Go on a run (important)", - "Clean the house", - "Go to the grocery store", - "Do some coding", - "Read a book (important)", - ] - -We could then take this list and "render" it into a series of ``<li>`` elements: - -.. testcode:: - - from reactpy import html - - list_item_elements = [html.li(text) for text in tasks] - -This list of elements can then be passed into a parent ``<ul>`` element: - -.. testcode:: - - list_element = html.ul(list_item_elements) - -The last thing we have to do is return this from a component: - -.. reactpy:: _examples/todo_from_list - - -Filtering and Sorting Elements ------------------------------- - -Our representation of ``tasks`` worked fine to just get them on the screen, but it -doesn't extend well to the case where we want to filter and order them based on -priority. Thus, we need to change the data structure we're using to represent our tasks: - -.. testcode:: - - tasks = [ - {"text": "Make breakfast", "priority": 0}, - {"text": "Feed the dog", "priority": 0}, - {"text": "Do laundry", "priority": 2}, - {"text": "Go on a run", "priority": 1}, - {"text": "Clean the house", "priority": 2}, - {"text": "Go to the grocery store", "priority": 2}, - {"text": "Do some coding", "priority": 1}, - {"text": "Read a book", "priority": 1}, - ] - -With this we can now imaging writing some filtering and sorting logic using Python's -:func:`filter` and :func:`sorted` functions respectively. We'll do this by only -displaying items whose ``priority`` is less than or equal to some ``filter_by_priority`` -and then ordering the elements based on the ``priority``: - -.. testcode:: - - filter_by_priority = 1 - sort_by_priority = True - - filtered_tasks = tasks - if filter_by_priority is not None: - filtered_tasks = [t for t in filtered_tasks if t["priority"] <= filter_by_priority] - if sort_by_priority: - filtered_tasks = list(sorted(filtered_tasks, key=lambda t: t["priority"])) - - assert filtered_tasks == [ - {'text': 'Make breakfast', 'priority': 0}, - {'text': 'Feed the dog', 'priority': 0}, - {'text': 'Go on a run', 'priority': 1}, - {'text': 'Do some coding', 'priority': 1}, - {'text': 'Read a book', 'priority': 1}, - ] - -We could then add this code to our ``DataList`` component: - -.. warning:: - - The code below produces a bunch of warnings! Be sure to tead the - :ref:`next section <Organizing Items With Keys>` to find out why. - -.. reactpy:: _examples/sorted_and_filtered_todo_list - - -Organizing Items With Keys --------------------------- - -If you run the examples above :ref:`in debug mode <Running ReactPy in Debug Mode>` you'll -see the server log a bunch of errors that look something like: - -.. code-block:: text - - Key not specified for child in list {'tagName': 'li', 'children': ...} - -What this is telling us is that we haven't specified a unique ``key`` for each of the -items in our todo list. In order to silence this warning we need to expand our data -structure even further to include a unique ID for each item in our todo list: - -.. testcode:: - - tasks = [ - {"id": 0, "text": "Make breakfast", "priority": 0}, - {"id": 1, "text": "Feed the dog", "priority": 0}, - {"id": 2, "text": "Do laundry", "priority": 2}, - {"id": 3, "text": "Go on a run", "priority": 1}, - {"id": 4, "text": "Clean the house", "priority": 2}, - {"id": 5, "text": "Go to the grocery store", "priority": 2}, - {"id": 6, "text": "Do some coding", "priority": 1}, - {"id": 7, "text": "Read a book", "priority": 1}, - ] - -Then, as we're constructing our ``<li>`` elements we'll declare a ``key`` attribute: - -.. code-block:: - - list_item_elements = [html.li({"key": t["id"]}, t["text"]) for t in tasks] - -This ``key`` tells ReactPy which ``<li>`` element corresponds to which item of data in our -``tasks`` list. This becomes important if the order or number of items in your list can -change. In our case, if we decided to change whether we want to ``filter_by_priority`` -or ``sort_by_priority`` the items in our ``<ul>`` element would change. Given this, -here's how we'd change our component: - -.. reactpy:: _examples/todo_list_with_keys - - -Keys for Components -................... - -Thus far we've been talking about passing keys to standard HTML elements. However, this -principle also applies to components too. Every function decorated with the -``@component`` decorator automatically gets a ``key`` parameter that operates in the -exact same way that it does for standard HTML elements: - -.. testcode:: - - from reactpy import component - - - @component - def ListItem(text): - return html.li(text) - - tasks = [ - {"id": 0, "text": "Make breakfast"}, - {"id": 1, "text": "Feed the dog"}, - {"id": 2, "text": "Do laundry"}, - {"id": 3, "text": "Go on a run"}, - {"id": 4, "text": "Clean the house"}, - {"id": 5, "text": "Go to the grocery store"}, - {"id": 6, "text": "Do some coding"}, - {"id": 7, "text": "Read a book"}, - ] - - list_element = [ListItem(t["text"], key=t["id"]) for t in tasks] - - -.. warning:: - - The ``key`` argument is reserved for this purpose. Defining a component with a - function that has a ``key`` parameter will cause an error: - - .. testcode:: - - from reactpy import component - - @component - def FunctionWithKeyParam(key): - ... - - .. testoutput:: - - Traceback (most recent call last): - ... - TypeError: Component render function ... uses reserved parameter 'key' - - -Rules of Keys -............. - -In order to avoid unexpected behaviors when rendering data with keys, there are a few -rules that need to be followed. These will ensure that each item of data is associated -with the correct UI element. - -.. dropdown:: Keys may be the same if their elements are not siblings - :color: info - - If two elements have different parents in the UI, they can use the same keys. - - .. testcode:: - - data_1 = [ - {"id": 1, "text": "Something"}, - {"id": 2, "text": "Something else"}, - ] - - data_2 = [ - {"id": 1, "text": "Another thing"}, - {"id": 2, "text": "Yet another thing"}, - ] - - html.section( - html.ul([html.li(data["text"], key=data["id"]) for data in data_1]), - html.ul([html.li(data["text"], key=data["id"]) for data in data_2]), - ) - -.. dropdown:: Keys must be unique amongst siblings - :color: danger - - Keys must be unique among siblings. - - .. testcode:: - - data = [ - {"id": 1, "text": "Something"}, - {"id": 2, "text": "Something else"}, - {"id": 1, "text": "Another thing"}, # BAD: has a duplicated id - {"id": 2, "text": "Yet another thing"}, # BAD: has a duplicated id - ] - - html.section( - html.ul([html.li(data["text"], key=data["id"]) for data in data]), - ) - -.. dropdown:: Keys must be fixed to their data. - :color: danger - - Don't generate random values for keys to avoid the warning. - - .. testcode:: - - from random import random - - data = [ - {"id": random(), "text": "Something"}, - {"id": random(), "text": "Something else"}, - {"id": random(), "text": "Another thing"}, - {"id": random(), "text": "Yet another thing"}, - ] - - html.section( - html.ul([html.li(data["text"], key=data["id"]) for data in data]), - ) - - Doing so will result in unexpected behavior. - -Since we've just been working with a small amount of sample data thus far, it was easy -enough for us to manually add an ``id`` key to each item of data. Often though, we have -to work with data that already exists. In those cases, how should we pick what value to -use for each ``key``? - -- If your data comes from your database you should use the keys and IDs generated by - that database since these are inherently unique. For example, you might use the - primary key of records in a relational database. - -- If your data is generated and persisted locally (e.g. notes in a note-taking app), use - an incrementing counter or :mod:`uuid` from the standard library when creating items. - - ----------- - - -.. card:: - :link: /guides/understanding-reactpy/why-reactpy-needs-keys - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn about why ReactPy needs keys in the first place. diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/bad_conditional_todo_list.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/bad_conditional_todo_list.py deleted file mode 100644 index 1ed3268b6..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/bad_conditional_todo_list.py +++ /dev/null @@ -1,24 +0,0 @@ -from reactpy import component, html, run - - -@component -def Item(name, done): - if done: - return html.li(name, " ✔") - else: - return html.li(name) - - -@component -def TodoList(): - return html.section( - html.h1("My Todo List"), - html.ul( - Item("Find a cool problem to solve", done=True), - Item("Build an app to solve it", done=True), - Item("Share that app with the world!", done=False), - ), - ) - - -run(TodoList) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/good_conditional_todo_list.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/good_conditional_todo_list.py deleted file mode 100644 index cd9ab6fc0..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/good_conditional_todo_list.py +++ /dev/null @@ -1,21 +0,0 @@ -from reactpy import component, html, run - - -@component -def Item(name, done): - return html.li(name, " ✔" if done else "") - - -@component -def TodoList(): - return html.section( - html.h1("My Todo List"), - html.ul( - Item("Find a cool problem to solve", done=True), - Item("Build an app to solve it", done=True), - Item("Share that app with the world!", done=False), - ), - ) - - -run(TodoList) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py deleted file mode 100644 index 96f8531d3..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py +++ /dev/null @@ -1,25 +0,0 @@ -from reactpy import component, html, run - - -@component -def Photo(): - return html.img( - { - "src": "https://picsum.photos/id/274/500/300", - "style": {"width": "30%"}, - "alt": "Ray Charles", - } - ) - - -@component -def Gallery(): - return html.section( - html.h1("Famous Musicians"), - Photo(), - Photo(), - Photo(), - ) - - -run(Gallery) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py deleted file mode 100644 index 665dd8c86..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py +++ /dev/null @@ -1,25 +0,0 @@ -from reactpy import component, html, run - - -@component -def Photo(alt_text, image_id): - return html.img( - { - "src": f"https://picsum.photos/id/{image_id}/500/200", - "style": {"width": "50%"}, - "alt": alt_text, - } - ) - - -@component -def Gallery(): - return html.section( - html.h1("Photo Gallery"), - Photo("Landscape", image_id=830), - Photo("City", image_id=274), - Photo("Puppy", image_id=237), - ) - - -run(Gallery) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py deleted file mode 100644 index 94fa6633f..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py +++ /dev/null @@ -1,15 +0,0 @@ -from reactpy import component, html, run - - -@component -def Photo(): - return html.img( - { - "src": "https://picsum.photos/id/237/500/300", - "style": {"width": "50%"}, - "alt": "Puppy", - } - ) - - -run(Photo) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/todo_list.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/todo_list.py deleted file mode 100644 index 2ffd09261..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/todo_list.py +++ /dev/null @@ -1,21 +0,0 @@ -from reactpy import component, html, run - - -@component -def Item(name, done): - return html.li(name) - - -@component -def TodoList(): - return html.section( - html.h1("My Todo List"), - html.ul( - Item("Find a cool problem to solve", done=True), - Item("Build an app to solve it", done=True), - Item("Share that app with the world!", done=False), - ), - ) - - -run(TodoList) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py deleted file mode 100644 index 58ed79dd8..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py +++ /dev/null @@ -1,13 +0,0 @@ -from reactpy import component, html, run - - -@component -def MyTodoList(): - return html.div( - html.h1("My Todo List"), - html.img({"src": "https://picsum.photos/id/0/500/300"}), - html.ul(html.li("The first thing I need to do is...")), - ) - - -run(MyTodoList) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py deleted file mode 100644 index cc54d8b71..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py +++ /dev/null @@ -1,13 +0,0 @@ -from reactpy import component, html, run - - -@component -def MyTodoList(): - return html._( - html.h1("My Todo List"), - html.img({"src": "https://picsum.photos/id/0/500/200"}), - html.ul(html.li("The first thing I need to do is...")), - ) - - -run(MyTodoList) diff --git a/docs/source/guides/creating-interfaces/your-first-components/index.rst b/docs/source/guides/creating-interfaces/your-first-components/index.rst deleted file mode 100644 index 00b53d1b7..000000000 --- a/docs/source/guides/creating-interfaces/your-first-components/index.rst +++ /dev/null @@ -1,134 +0,0 @@ -Your First Components -===================== - -As we learned :ref:`earlier <HTML with ReactPy>` we can use ReactPy to make rich structured -documents out of standard HTML elements. As these documents become larger and more -complex though, working with these tiny UI elements can become difficult. When this -happens, ReactPy allows you to group these elements together info "components". These -components can then be reused throughout your application. - - -Defining a Component --------------------- - -At their core, components are just normal Python functions that return HTML. To define a -component you just need to add a ``@component`` `decorator -<https://realpython.com/primer-on-python-decorators/>`__ to a function. Functions -decorator in this way are known as **render function** and, by convention, we name them -like classes - with ``CamelCase``. So consider what we would do if we wanted to write, -and then :ref:`display <Running ReactPy>` a ``Photo`` component: - -.. reactpy:: _examples/simple_photo - -.. warning:: - - If we had not decorated our ``Photo``'s render function with the ``@component`` - decorator, the server would start, but as soon as we tried to view the page it would - be blank. The servers logs would then indicate: - - .. code-block:: text - - TypeError: Expected a ComponentType, not dict. - - -Using a Component ------------------ - -Having defined our ``Photo`` component we can now nest it inside of other components. We -can define a "parent" ``Gallery`` component that returns one or more ``Profile`` -components. This is part of what makes components so powerful - you can define a -component once and use it wherever and however you need to: - -.. reactpy:: _examples/nested_photos - - -Return a Single Root Element ----------------------------- - -Components must return a "single root element". That one root element may have children, -but you cannot for example, return a list of element from a component and expect it to -be rendered correctly. If you want to return multiple elements you must wrap them in -something like a :func:`html.div <reactpy.html.div>`: - -.. reactpy:: _examples/wrap_in_div - -If don't want to add an extra ``div`` you can use a "fragment" instead with the -:func:`html._ <reactpy.html._>` function: - -.. reactpy:: _examples/wrap_in_fragment - -Fragments allow you to group elements together without leaving any trace in the UI. For -example, the first code sample written with ReactPy will produce the second HTML code -block: - -.. grid:: 1 2 2 2 - :margin: 0 - :padding: 0 - - .. grid-item:: - - .. testcode:: - - from reactpy import html - - html.ul( - html._( - html.li("Group 1 Item 1"), - html.li("Group 1 Item 2"), - html.li("Group 1 Item 3"), - ), - html._( - html.li("Group 2 Item 1"), - html.li("Group 2 Item 2"), - html.li("Group 2 Item 3"), - ) - ) - - .. grid-item:: - - .. code-block:: html - - <ul> - <li>Group 1 Item 1</li> - <li>Group 1 Item 2</li> - <li>Group 1 Item 3</li> - <li>Group 2 Item 1</li> - <li>Group 2 Item 2</li> - <li>Group 2 Item 3</li> - </ul> - - - -Parametrizing Components ------------------------- - -Since components are just regular functions, you can add parameters to them. This allows -parent components to pass information to child components. Where standard HTML elements -are parametrized by dictionaries, since components behave like typical functions you can -give them positional and keyword arguments as you would normally: - -.. reactpy:: _examples/parametrized_photos - - -Conditional Rendering ---------------------- - -Your components will often need to display different things depending on different -conditions. Let's imagine that we had a basic todo list where only some of the items -have been completed. Below we have a basic implementation for such a list except that -the ``Item`` component doesn't change based on whether it's ``done``: - -.. reactpy:: _examples/todo_list - -Let's imagine that we want to add a ✔ to the items which have been marked ``done=True``. -One way to do this might be to write an ``if`` statement where we return one ``li`` -element if the item is ``done`` and a different one if it's not: - -.. reactpy:: _examples/bad_conditional_todo_list - -As you can see this accomplishes our goal! However, notice how similar ``html.li(name, " -✔")`` and ``html.li(name)`` are. While in this case it isn't especially harmful, we -could make our code a little easier to read and maintain by using an "inline" ``if`` -statement. - -.. reactpy:: _examples/good_conditional_todo_list diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py deleted file mode 100644 index 3ad4dac5b..000000000 --- a/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py +++ /dev/null @@ -1,16 +0,0 @@ -from reactpy import component, run, web - -mui = web.module_from_template( - "react@^17.0.0", - "@material-ui/core@4.12.4", - fallback="⌛", -) -Button = web.export(mui, "Button") - - -@component -def HelloWorld(): - return Button({"color": "primary", "variant": "contained"}, "Hello World!") - - -run(HelloWorld) diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py deleted file mode 100644 index 3fc684005..000000000 --- a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py +++ /dev/null @@ -1,30 +0,0 @@ -import json - -import reactpy - -mui = reactpy.web.module_from_template( - "react@^17.0.0", - "@material-ui/core@4.12.4", - fallback="⌛", -) -Button = reactpy.web.export(mui, "Button") - - -@reactpy.component -def ViewButtonEvents(): - event, set_event = reactpy.hooks.use_state(None) - - return reactpy.html.div( - Button( - { - "color": "primary", - "variant": "contained", - "onClick": lambda event: set_event(event), - }, - "Click Me!", - ), - reactpy.html.pre(json.dumps(event, indent=2)), - ) - - -reactpy.run(ViewButtonEvents) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py b/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py deleted file mode 100644 index 4640785f8..000000000 --- a/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py +++ /dev/null @@ -1,32 +0,0 @@ -from pathlib import Path - -from reactpy import component, run, web - -file = Path(__file__).parent / "super-simple-chart.js" -ssc = web.module_from_file("super-simple-chart", file, fallback="⌛") -SuperSimpleChart = web.export(ssc, "SuperSimpleChart") - - -@component -def App(): - return SuperSimpleChart( - { - "data": [ - {"x": 1, "y": 2}, - {"x": 2, "y": 4}, - {"x": 3, "y": 7}, - {"x": 4, "y": 3}, - {"x": 5, "y": 5}, - {"x": 6, "y": 9}, - {"x": 7, "y": 6}, - ], - "height": 300, - "width": 500, - "color": "royalblue", - "lineWidth": 4, - "axisColor": "silver", - } - ) - - -run(App) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js deleted file mode 100644 index 486e5c363..000000000 --- a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js +++ /dev/null @@ -1,82 +0,0 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; - -const html = htm.bind(h); - -export function bind(node, config) { - return { - create: (component, props, children) => h(component, props, ...children), - render: (element) => render(element, node), - unmount: () => render(null, node), - }; -} - -export function SuperSimpleChart(props) { - const data = props.data; - const lastDataIndex = data.length - 1; - - const options = { - height: props.height || 100, - width: props.width || 100, - color: props.color || "blue", - lineWidth: props.lineWidth || 2, - axisColor: props.axisColor || "black", - }; - - const xData = data.map((point) => point.x); - const yData = data.map((point) => point.y); - - const domain = { - xMin: Math.min(...xData), - xMax: Math.max(...xData), - yMin: Math.min(...yData), - yMax: Math.max(...yData), - }; - - return html`<svg - width="${options.width}px" - height="${options.height}px" - viewBox="0 0 ${options.width} ${options.height}" - > - ${makePath(props, domain, data, options)} ${makeAxis(props, options)} - </svg>`; -} - -function makePath(props, domain, data, options) { - const { xMin, xMax, yMin, yMax } = domain; - const { width, height } = options; - const getSvgX = (x) => ((x - xMin) / (xMax - xMin)) * width; - const getSvgY = (y) => height - ((y - yMin) / (yMax - yMin)) * height; - - let pathD = - `M ${getSvgX(data[0].x)} ${getSvgY(data[0].y)} ` + - data.map(({ x, y }, i) => `L ${getSvgX(x)} ${getSvgY(y)}`).join(" "); - - return html`<path - d="${pathD}" - style=${{ - stroke: options.color, - strokeWidth: options.lineWidth, - fill: "none", - }} - />`; -} - -function makeAxis(props, options) { - return html`<g> - <line - x1="0" - y1=${options.height} - x2=${options.width} - y2=${options.height} - style=${{ stroke: options.axisColor, strokeWidth: options.lineWidth * 2 }} - /> - <line - x1="0" - y1="0" - x2="0" - y2=${options.height} - style=${{ stroke: options.axisColor, strokeWidth: options.lineWidth * 2 }} - /> - </g>`; -} diff --git a/docs/source/guides/escape-hatches/distributing-javascript.rst b/docs/source/guides/escape-hatches/distributing-javascript.rst deleted file mode 100644 index 9eb478965..000000000 --- a/docs/source/guides/escape-hatches/distributing-javascript.rst +++ /dev/null @@ -1,307 +0,0 @@ -Distributing Javascript -======================= - -There are two ways that you can distribute your :ref:`Custom Javascript Components`: - -- Using a CDN_ -- In a Python package via PyPI_ - -These options are not mutually exclusive though, and it may be beneficial to support -both options. For example, if you upload your Javascript components to NPM_ and also -bundle your Javascript inside a Python package, in principle your users can determine -which work best for them. Regardless though, either you or, if you give then the choice, -your users, will have to consider the tradeoffs of either approach. - -- :ref:`Distributing Javascript via CDN_` - Most useful in production-grade applications - where its assumed the user has a network connection. In this scenario a CDN's `edge - network <https://en.wikipedia.org/wiki/Edge_computing>`__ can be used to bring the - Javascript source closer to the user in order to reduce page load times. - -- :ref:`Distributing Javascript via PyPI_` - This method is ideal for local usage since - the user can server all the Javascript components they depend on from their computer - without requiring a network connection. - - -Distributing Javascript via CDN_ --------------------------------- - -Under this approach, to simplify these instructions, we're going to ignore the problem -of distributing the Javascript since that must be handled by your CDN. For open source -or personal projects, a CDN like https://unpkg.com/ makes things easy by automatically -preparing any package that's been uploaded to NPM_. If you need to roll with your own -private CDN, this will likely be more complicated. - -In either case though, on the Python side, things are quite simple. You need only pass -the URL where your package can be found to :func:`~reactpy.web.module.module_from_url` -where you can then load any of its exports: - -.. code-block:: - - import reactpy - - your_module = ido.web.module_from_url("https://some.cdn/your-module") - YourComponent = reactpy.web.export(your_module, "YourComponent") - - -Distributing Javascript via PyPI_ ---------------------------------- - -This can be most easily accomplished by using the `template repository`_ that's been -purpose-built for this. However, to get a better sense for its inner workings, we'll -briefly look at what's required. At a high level, we must consider how to... - -1. bundle your Javascript into an `ECMAScript Module`) -2. include that Javascript bundle in a Python package -3. use it as a component in your application using ReactPy - -In the descriptions to follow we'll be assuming that: - -- NPM_ is the Javascript package manager -- The components are implemented with React_ -- Rollup_ bundles the Javascript module -- Setuptools_ builds the Python package - -To start, let's take a look at the file structure we'll be building: - -.. code-block:: text - - your-project - |-- js - | |-- src - | | \-- index.js - | |-- package.json - | \-- rollup.config.js - |-- your_python_package - | |-- __init__.py - | \-- widget.py - |-- Manifest.in - |-- pyproject.toml - \-- setup.py - -``index.js`` should contain the relevant exports (see -:ref:`Custom JavaScript Components` for more info): - -.. code-block:: javascript - - import * as React from "react"; - import * as ReactDOM from "react-dom"; - - export function bind(node, config) { - return { - create: (component, props, children) => - React.createElement(component, props, ...children), - render: (element) => ReactDOM.render(element, node), - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; - } - - // exports for your components - export YourFirstComponent(props) {...}; - export YourSecondComponent(props) {...}; - export YourThirdComponent(props) {...}; - - -Your ``package.json`` should include the following: - -.. code-block:: python - - { - "name": "YOUR-PACKAGE-NAME", - "scripts": { - "build": "rollup --config", - ... - }, - "devDependencies": { - "rollup": "^2.35.1", - "rollup-plugin-commonjs": "^10.1.0", - "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-replace": "^2.2.0", - ... - }, - "dependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1", - "@reactpy/client": "^0.8.5", - ... - }, - ... - } - -Getting a bit more in the weeds now, your ``rollup.config.js`` file should be designed -such that it drops an ES Module at ``your-project/your_python_package/bundle.js`` since -we'll be writing ``widget.py`` under that assumption. - -.. note:: - - Don't forget to ignore this ``bundle.js`` file when committing code (with a - ``.gitignore`` if you're using Git) since it can always rebuild from the raw - Javascript source in ``your-project/js``. - -.. code-block:: javascript - - import resolve from "rollup-plugin-node-resolve"; - import commonjs from "rollup-plugin-commonjs"; - import replace from "rollup-plugin-replace"; - - export default { - input: "src/index.js", - output: { - file: "../your_python_package/bundle.js", - format: "esm", - }, - plugins: [ - resolve(), - commonjs(), - replace({ - "process.env.NODE_ENV": JSON.stringify("production"), - }), - ] - }; - -Your ``widget.py`` file should then load the neighboring bundle file using -:func:`~reactpy.web.module.module_from_file`. Then components from that bundle can be -loaded with :func:`~reactpy.web.module.export`. - -.. code-block:: - - from pathlib import Path - - import reactpy - - _BUNDLE_PATH = Path(__file__).parent / "bundle.js" - _WEB_MODULE = reactpy.web.module_from_file( - # Note that this is the same name from package.json - this must be globally - # unique since it must share a namespace with all other javascript packages. - name="YOUR-PACKAGE-NAME", - file=_BUNDLE_PATH, - # What to temporarily display while the module is being loaded - fallback="Loading...", - ) - - # Your module must provide a named export for YourFirstComponent - YourFirstComponent = reactpy.web.export(_WEB_MODULE, "YourFirstComponent") - - # It's possible to export multiple components at once - YourSecondComponent, YourThirdComponent = reactpy.web.export( - _WEB_MODULE, ["YourSecondComponent", "YourThirdComponent"] - ) - -.. note:: - - When :data:`reactpy.config.REACTPY_DEBUG_MODE` is active, named exports will be validated. - -The remaining files that we need to create are concerned with creating a Python package. -We won't cover all the details here, so refer to the Setuptools_ documentation for -more information. With that said, the first file to fill out is `pyproject.toml` since -we need to declare what our build tool is (in this case Setuptools): - -.. code-block:: toml - - [build-system] - requires = ["setuptools>=40.8.0", "wheel"] - build-backend = "setuptools.build_meta" - -Then, we can create the ``setup.py`` file which uses Setuptools. This will differ -substantially from a normal ``setup.py`` file since, as part of the build process we'll -need to use NPM to bundle our Javascript. This requires customizing some of the build -commands in Setuptools like ``build``, ``sdist``, and ``develop``: - -.. code-block:: python - - import subprocess - from pathlib import Path - - from setuptools import setup, find_packages - from distutils.command.build import build - from distutils.command.sdist import sdist - from setuptools.command.develop import develop - - PACKAGE_SPEC = {} # gets passed to setup() at the end - - - # ----------------------------------------------------------------------------- - # General Package Info - # ----------------------------------------------------------------------------- - - - PACKAGE_NAME = "your_python_package" - - PACKAGE_SPEC.update( - name=PACKAGE_NAME, - version="0.0.1", - packages=find_packages(exclude=["tests*"]), - classifiers=["Framework :: ReactPy", ...], - keywords=["ReactPy", "components", ...], - # install ReactPy with this package - install_requires=["reactpy"], - # required in order to include static files like bundle.js using MANIFEST.in - include_package_data=True, - # we need access to the file system, so cannot be run from a zip file - zip_safe=False, - ) - - - # ---------------------------------------------------------------------------- - # Build Javascript - # ---------------------------------------------------------------------------- - - - # basic paths used to gather files - PROJECT_ROOT = Path(__file__).parent - PACKAGE_DIR = PROJECT_ROOT / PACKAGE_NAME - JS_DIR = PROJECT_ROOT / "js" - - - def build_javascript_first(cls): - class Command(cls): - def run(self): - for cmd_str in ["npm install", "npm run build"]: - subprocess.run(cmd_str.split(), cwd=str(JS_DIR), check=True) - super().run() - - return Command - - - package["cmdclass"] = { - "sdist": build_javascript_first(sdist), - "build": build_javascript_first(build), - "develop": build_javascript_first(develop), - } - - - # ----------------------------------------------------------------------------- - # Run It - # ----------------------------------------------------------------------------- - - - if __name__ == "__main__": - setup(**package) - - -Finally, since we're using ``include_package_data`` you'll need a MANIFEST.in_ file that -includes ``bundle.js``: - -.. code-block:: text - - include your_python_package/bundle.js - -And that's it! While this might seem like a lot of work, you're always free to start -creating your custom components using the provided `template repository`_ so you can get -up and running as quickly as possible. - - -.. Links -.. ===== - -.. _NPM: https://www.npmjs.com -.. _install NPM: https://www.npmjs.com/get-npm -.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network -.. _PyPI: https://pypi.org/ -.. _template repository: https://github.com/reactive-python/reactpy-js-component-template -.. _web module: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules -.. _Rollup: https://rollupjs.org/guide/en/ -.. _Webpack: https://webpack.js.org/ -.. _Setuptools: https://setuptools.readthedocs.io/en/latest/userguide/index.html -.. _ECMAScript Module: https://tc39.es/ecma262/#sec-modules -.. _React: https://reactjs.org -.. _MANIFEST.in: https://packaging.python.org/guides/using-manifest-in/ diff --git a/docs/source/guides/escape-hatches/index.rst b/docs/source/guides/escape-hatches/index.rst deleted file mode 100644 index 3ef1b7122..000000000 --- a/docs/source/guides/escape-hatches/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -Escape Hatches -============== - -.. toctree:: - :hidden: - - javascript-components - distributing-javascript - using-a-custom-backend - using-a-custom-client - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/escape-hatches/javascript-components.rst b/docs/source/guides/escape-hatches/javascript-components.rst deleted file mode 100644 index f0a71b6b7..000000000 --- a/docs/source/guides/escape-hatches/javascript-components.rst +++ /dev/null @@ -1,146 +0,0 @@ -.. _Javascript Component: - -Javascript Components -===================== - -While ReactPy is a great tool for displaying HTML and responding to browser events with -pure Python, there are other projects which already allow you to do this inside -`Jupyter Notebooks <https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Basics.html>`__ -or in standard -`web apps <https://blog.jupyter.org/and-voil%C3%A0-f6a2c08a4a93?gi=54b835a2fcce>`__. -The real power of ReactPy comes from its ability to seamlessly leverage the existing -Javascript ecosystem. This can be accomplished in different ways for different reasons: - -.. list-table:: - :header-rows: 1 - - * - Integration Method - - Use Case - - * - :ref:`Dynamically Loaded Components` - - You want to **quickly experiment** with ReactPy and the Javascript ecosystem. - - * - :ref:`Custom Javascript Components` - - You want to create polished software that can be **easily shared** with others. - - -.. _Dynamically Loaded Component: - -Dynamically Loaded Components ------------------------------ - -.. note:: - - This method is not recommended in production systems - see :ref:`Distributing - Javascript` for more info. Instead, it's best used during exploratory phases of - development. - -ReactPy makes it easy to draft your code when you're in the early stages of development by -using a CDN_ to dynamically load Javascript packages on the fly. In this example we'll -be using the ubiquitous React-based UI framework `Material UI`_. - -.. reactpy:: _examples/material_ui_button_no_action - -So now that we can display a Material UI Button we probably want to make it do -something. Thankfully there's nothing new to learn here, you can pass event handlers to -the button just as you did when :ref:`getting started <responding to events>`. Thus, all -we need to do is add an ``onClick`` handler to the component: - -.. reactpy:: _examples/material_ui_button_on_click - - -.. _Custom Javascript Component: - -Custom Javascript Components ----------------------------- - -For projects that will be shared with others, we recommend bundling your Javascript with -Rollup_ or Webpack_ into a `web module`_. ReactPy also provides a `template repository`_ -that can be used as a blueprint to build a library of React components. - -To work as intended, the Javascript bundle must export a function ``bind()`` that -adheres to the following interface: - -.. code-block:: typescript - - type EventData = { - target: string; - data: Array<any>; - } - - type LayoutContext = { - sendEvent(data: EventData) => void; - loadImportSource(source: string, sourceType: "NAME" | "URL") => Module; - } - - type bind = (node: HTMLElement, context: LayoutContext) => ({ - create(type: any, props: Object, children: Array<any>): any; - render(element): void; - unmount(): void; - }); - -.. note:: - - - ``node`` is the ``HTMLElement`` that ``render()`` should mount to. - - - ``context`` can send events back to the server and load "import sources" - (like a custom component module). - - - ``type`` is a named export of the current module, or a string (e.g. ``"div"``, - ``"button"``, etc.) - - - ``props`` is an object containing attributes and callbacks for the given - ``component``. - - - ``children`` is an array of elements which were constructed by recursively calling - ``create``. - -The interface returned by ``bind()`` can be thought of as being similar to that of -React. - -- ``create`` ➜ |React.createElement|_ -- ``render`` ➜ |ReactDOM.render|_ -- ``unmount`` ➜ |ReactDOM.unmountComponentAtNode|_ - -.. |React.createElement| replace:: ``React.createElement`` -.. _React.createElement: https://reactjs.org/docs/react-api.html#createelement - -.. |ReactDOM.render| replace:: ``ReactDOM.render`` -.. _ReactDOM.render: https://reactjs.org/docs/react-dom.html#render - -.. |ReactDOM.unmountComponentAtNode| replace:: ``ReactDOM.unmountComponentAtNode`` -.. _ReactDOM.unmountComponentAtNode: https://reactjs.org/docs/react-api.html#createelement - -It will be used in the following manner: - -.. code-block:: javascript - - // once on mount - const binding = bind(node, context); - - // on every render - let element = binding.create(type, props, children) - binding.render(element); - - // once on unmount - binding.unmount(); - -The simplest way to try this out yourself though, is to hook in a simple hand-crafted -Javascript module that has the requisite interface. In the example to follow we'll -create a very basic SVG line chart. The catch though is that we are limited to using -Javascript that can run directly in the browser. This means we can't use fancy syntax -like `JSX <https://reactjs.org/docs/introducing-jsx.html>`__ and instead will use -`htm <https://github.com/developit/htm>`__ to simulate JSX in plain Javascript. - -.. reactpy:: _examples/super_simple_chart - - -.. Links -.. ===== - -.. _Material UI: https://material-ui.com/ -.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network -.. _template repository: https://github.com/reactive-python/reactpy-js-component-template -.. _web module: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules -.. _Rollup: https://rollupjs.org/guide/en/ -.. _Webpack: https://webpack.js.org/ diff --git a/docs/source/guides/escape-hatches/using-a-custom-backend.rst b/docs/source/guides/escape-hatches/using-a-custom-backend.rst deleted file mode 100644 index f9d21208a..000000000 --- a/docs/source/guides/escape-hatches/using-a-custom-backend.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _Writing Your Own Backend: -.. _Using a Custom Backend: - -Using a Custom Backend 🚧 -========================= - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/escape-hatches/using-a-custom-client.rst b/docs/source/guides/escape-hatches/using-a-custom-client.rst deleted file mode 100644 index 95de23e59..000000000 --- a/docs/source/guides/escape-hatches/using-a-custom-client.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _Writing Your Own Client: -.. _Using a Custom Client: - -Using a Custom Client 🚧 -======================== - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/getting-started/_examples/debug_error_example.py b/docs/source/guides/getting-started/_examples/debug_error_example.py deleted file mode 100644 index dd0b212ab..000000000 --- a/docs/source/guides/getting-started/_examples/debug_error_example.py +++ /dev/null @@ -1,20 +0,0 @@ -from reactpy import component, html, run - - -@component -def App(): - return html.div(GoodComponent(), BadComponent()) - - -@component -def GoodComponent(): - return html.p("This component rendered successfully") - - -@component -def BadComponent(): - msg = "This component raised an error" - raise RuntimeError(msg) - - -run(App) diff --git a/docs/source/guides/getting-started/_examples/hello_world.py b/docs/source/guides/getting-started/_examples/hello_world.py deleted file mode 100644 index f38d9e38f..000000000 --- a/docs/source/guides/getting-started/_examples/hello_world.py +++ /dev/null @@ -1,9 +0,0 @@ -from reactpy import component, html, run - - -@component -def App(): - return html.h1("Hello, world!") - - -run(App) diff --git a/docs/source/guides/getting-started/_examples/run_fastapi.py b/docs/source/guides/getting-started/_examples/run_fastapi.py deleted file mode 100644 index bb02e9d6a..000000000 --- a/docs/source/guides/getting-started/_examples/run_fastapi.py +++ /dev/null @@ -1,22 +0,0 @@ -# :lines: 11- - -from reactpy import run -from reactpy.backend import fastapi as fastapi_server - -# the run() function is the entry point for examples -fastapi_server.configure = lambda _, cmpt: run(cmpt) - - -from fastapi import FastAPI - -from reactpy import component, html -from reactpy.backend.fastapi import configure - - -@component -def HelloWorld(): - return html.h1("Hello, world!") - - -app = FastAPI() -configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_flask.py b/docs/source/guides/getting-started/_examples/run_flask.py deleted file mode 100644 index f98753784..000000000 --- a/docs/source/guides/getting-started/_examples/run_flask.py +++ /dev/null @@ -1,22 +0,0 @@ -# :lines: 11- - -from reactpy import run -from reactpy.backend import flask as flask_server - -# the run() function is the entry point for examples -flask_server.configure = lambda _, cmpt: run(cmpt) - - -from flask import Flask - -from reactpy import component, html -from reactpy.backend.flask import configure - - -@component -def HelloWorld(): - return html.h1("Hello, world!") - - -app = Flask(__name__) -configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_sanic.py b/docs/source/guides/getting-started/_examples/run_sanic.py deleted file mode 100644 index 1dae9f6e0..000000000 --- a/docs/source/guides/getting-started/_examples/run_sanic.py +++ /dev/null @@ -1,26 +0,0 @@ -# :lines: 11- - -from reactpy import run -from reactpy.backend import sanic as sanic_server - -# the run() function is the entry point for examples -sanic_server.configure = lambda _, cmpt: run(cmpt) - - -from sanic import Sanic - -from reactpy import component, html -from reactpy.backend.sanic import configure - - -@component -def HelloWorld(): - return html.h1("Hello, world!") - - -app = Sanic("MyApp") -configure(app, HelloWorld) - - -if __name__ == "__main__": - app.run(port=8000) diff --git a/docs/source/guides/getting-started/_examples/run_starlette.py b/docs/source/guides/getting-started/_examples/run_starlette.py deleted file mode 100644 index 966b9ef77..000000000 --- a/docs/source/guides/getting-started/_examples/run_starlette.py +++ /dev/null @@ -1,22 +0,0 @@ -# :lines: 11- - -from reactpy import run -from reactpy.backend import starlette as starlette_server - -# the run() function is the entry point for examples -starlette_server.configure = lambda _, cmpt: run(cmpt) - - -from starlette.applications import Starlette - -from reactpy import component, html -from reactpy.backend.starlette import configure - - -@component -def HelloWorld(): - return html.h1("Hello, world!") - - -app = Starlette() -configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_tornado.py b/docs/source/guides/getting-started/_examples/run_tornado.py deleted file mode 100644 index b86126e63..000000000 --- a/docs/source/guides/getting-started/_examples/run_tornado.py +++ /dev/null @@ -1,31 +0,0 @@ -# :lines: 11- - -from reactpy import run -from reactpy.backend import tornado as tornado_server - -# the run() function is the entry point for examples -tornado_server.configure = lambda _, cmpt: run(cmpt) - - -import tornado.ioloop -import tornado.web - -from reactpy import component, html -from reactpy.backend.tornado import configure - - -@component -def HelloWorld(): - return html.h1("Hello, world!") - - -def make_app(): - app = tornado.web.Application() - configure(app, HelloWorld) - return app - - -if __name__ == "__main__": - app = make_app() - app.listen(8000) - tornado.ioloop.IOLoop.current().start() diff --git a/docs/source/guides/getting-started/_examples/sample_app.py b/docs/source/guides/getting-started/_examples/sample_app.py deleted file mode 100644 index a1cc34e6d..000000000 --- a/docs/source/guides/getting-started/_examples/sample_app.py +++ /dev/null @@ -1,3 +0,0 @@ -import reactpy - -reactpy.run(reactpy.sample.SampleApp) diff --git a/docs/source/guides/getting-started/_static/embed-doc-ex.html b/docs/source/guides/getting-started/_static/embed-doc-ex.html deleted file mode 100644 index 589cb5d80..000000000 --- a/docs/source/guides/getting-started/_static/embed-doc-ex.html +++ /dev/null @@ -1,8 +0,0 @@ -<div id="reactpy-app" /> -<script type="module"> - import { mountLayoutWithWebSocket } from "https://esm.sh/@reactpy/client"; - mountLayoutWithWebSocket( - document.getElementById("reactpy-app"), - "wss://reactpy.dev/_reactpy/stream?view_id=todo" - ); -</script> diff --git a/docs/source/guides/getting-started/_static/embed-reactpy-view/index.html b/docs/source/guides/getting-started/_static/embed-reactpy-view/index.html deleted file mode 100644 index 146d715e4..000000000 --- a/docs/source/guides/getting-started/_static/embed-reactpy-view/index.html +++ /dev/null @@ -1,31 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="UTF-8" /> - <title>Example App</title> - </head> - <body> - <h1>This is an Example App</h1> - <p>Just below is an embedded ReactPy view...</p> - <div id="reactpy-app" /> - <script type="module"> - import { - mountWithLayoutServer, - LayoutServerInfo, - } from "https://esm.sh/@reactpy/client@0.38.0"; - - const serverInfo = new LayoutServerInfo({ - host: document.location.hostname, - port: document.location.port, - path: "_reactpy", - query: queryParams.user.toString(), - secure: document.location.protocol == "https:", - }); - - mountLayoutWithWebSocket( - document.getElementById("reactpy-app"), - serverInfo - ); - </script> - </body> -</html> diff --git a/docs/source/guides/getting-started/_static/embed-reactpy-view/main.py b/docs/source/guides/getting-started/_static/embed-reactpy-view/main.py deleted file mode 100644 index 6e3687f27..000000000 --- a/docs/source/guides/getting-started/_static/embed-reactpy-view/main.py +++ /dev/null @@ -1,22 +0,0 @@ -from sanic import Sanic -from sanic.response import file - -from reactpy import component, html -from reactpy.backend.sanic import Options, configure - -app = Sanic("MyApp") - - -@app.route("/") -async def index(request): - return await file("index.html") - - -@component -def ReactPyView(): - return html.code("This text came from an ReactPy App") - - -configure(app, ReactPyView, Options(url_prefix="/_reactpy")) - -app.run(host="127.0.0.1", port=5000) diff --git a/docs/source/guides/getting-started/_static/embed-reactpy-view/screenshot.png b/docs/source/guides/getting-started/_static/embed-reactpy-view/screenshot.png deleted file mode 100644 index 7439c83cf..000000000 Binary files a/docs/source/guides/getting-started/_static/embed-reactpy-view/screenshot.png and /dev/null differ diff --git a/docs/source/guides/getting-started/_static/logo-django.svg b/docs/source/guides/getting-started/_static/logo-django.svg deleted file mode 100644 index 1538f0817..000000000 --- a/docs/source/guides/getting-started/_static/logo-django.svg +++ /dev/null @@ -1,38 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Generator: Adobe Illustrator 12.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 51448) --> -<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ - <!ENTITY ns_svg "http://www.w3.org/2000/svg"> - <!ENTITY ns_xlink "http://www.w3.org/1999/xlink"> -]> -<svg version="1.0" id="Layer_1" xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" width="436.505" height="152.503" - viewBox="0 0 436.505 152.503" overflow="visible" enable-background="new 0 0 436.505 152.503" xml:space="preserve"> -<g> - <g> - <path fill="#092E20" d="M51.464,0h23.872v110.496c-12.246,2.325-21.237,3.255-31.002,3.255C15.191,113.75,0,100.576,0,75.308 - c0-24.337,16.122-40.147,41.078-40.147c3.875,0,6.82,0.309,10.386,1.239V0z M51.464,55.62c-2.79-0.929-5.115-1.239-8.06-1.239 - c-12.091,0-19.067,7.441-19.067,20.461c0,12.712,6.666,19.687,18.912,19.687c2.634,0,4.805-0.155,8.215-0.619V55.62z"/> - <path fill="#092E20" d="M113.312,36.865v55.338c0,19.067-1.395,28.212-5.58,36.118c-3.876,7.597-8.992,12.401-19.532,17.672 - l-22.167-10.541c10.541-4.96,15.656-9.299,18.911-15.967c3.411-6.82,4.497-14.726,4.497-35.497V36.865H113.312z M89.441,0.127 - h23.871v24.492H89.441V0.127z"/> - <path fill="#092E20" d="M127.731,42.29c10.542-4.959,20.617-7.129,31.623-7.129c12.246,0,20.306,3.254,23.872,9.61 - c2.014,3.565,2.634,8.215,2.634,18.137v48.517c-10.697,1.55-24.182,2.636-34.102,2.636c-19.996,0-28.988-6.978-28.988-22.478 - c0-16.742,11.936-24.492,41.234-26.973v-5.27c0-4.34-2.17-5.889-8.216-5.889c-8.835,0-18.756,2.48-28.058,7.286V42.29z - M165.089,80.268c-15.812,1.55-20.927,4.031-20.927,10.231c0,4.65,2.946,6.82,9.456,6.82c3.566,0,6.82-0.309,11.471-1.084V80.268z - "/> - <path fill="#092E20" d="M197.487,40.585c14.105-3.72,25.731-5.424,37.512-5.424c12.246,0,21.082,2.789,26.354,8.215 - c4.96,5.114,6.509,10.694,6.509,22.632v46.812H243.99V66.938c0-9.145-3.1-12.557-11.625-12.557c-3.255,0-6.2,0.31-11.007,1.705 - v56.734h-23.871V40.585z"/> - <path fill="#092E20" d="M277.142,125.842c8.372,4.34,16.742,6.354,25.577,6.354c15.655,0,22.321-6.354,22.321-21.546 - c0-0.155,0-0.31,0-0.465c-4.65,2.324-9.301,3.255-15.5,3.255c-20.927,0-34.26-13.796-34.26-35.652 - c0-27.129,19.688-42.473,54.564-42.473c10.232,0,19.688,1.084,31.159,3.409l-8.174,17.219c-6.356-1.24-0.509-0.166-5.312-0.631 - v2.481l0.309,10.074l0.154,13.022c0.155,3.254,0.155,6.51,0.311,9.765c0,2.945,0,4.341,0,6.511c0,20.462-1.705,30.072-6.82,37.977 - c-7.441,11.627-20.307,17.362-38.598,17.362c-9.301,0-17.36-1.395-25.732-4.651V125.842z M324.576,54.536c-0.31,0-0.619,0-0.774,0 - h-1.706c-4.649-0.155-10.074,1.084-13.796,3.409c-5.734,3.256-8.681,9.147-8.681,17.517c0,11.937,5.892,18.757,16.432,18.757 - c3.255,0,5.891-0.621,8.99-1.55v-1.706v-6.509c0-2.79-0.154-5.892-0.154-9.146l-0.154-11.005l-0.156-7.906V54.536z"/> - <path fill="#092E20" d="M398.062,34.85c23.871,0,38.443,15.037,38.443,39.373c0,24.958-15.19,40.614-39.373,40.614 - c-23.873,0-38.599-15.037-38.599-39.218C358.534,50.505,373.726,34.85,398.062,34.85z M397.595,95.614 - c9.147,0,14.573-7.596,14.573-20.772c0-13.02-5.271-20.771-14.415-20.771c-9.457,0-14.884,7.597-14.884,20.771 - C382.87,88.019,388.296,95.614,397.595,95.614z"/> - </g> -</g> -</svg> diff --git a/docs/source/guides/getting-started/_static/logo-jupyter.svg b/docs/source/guides/getting-started/_static/logo-jupyter.svg deleted file mode 100644 index fb2921a41..000000000 --- a/docs/source/guides/getting-started/_static/logo-jupyter.svg +++ /dev/null @@ -1,88 +0,0 @@ -<svg width="189" height="51" viewBox="0 0 189 51" version="2.0" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:figma="http://www.figma.com/figma/ns"> -<title>logo.svg</title> -<desc>Created using Figma 0.90</desc> -<g id="Canvas" transform="translate(-1638 -2093)" figma:type="canvas"> -<g id="logo" style="mix-blend-mode:normal;" figma:type="group"> -<g id="Group" style="mix-blend-mode:normal;" figma:type="group"> -<g id="g" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path0 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path0_fill" transform="translate(1688.87 2106.23)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path1 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path1_fill" transform="translate(1705.38 2106.19)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path2 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path2_fill" transform="translate(1730.18 2105.67)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path3 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path3_fill" transform="translate(1752.94 2106.21)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path4 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path4_fill" transform="translate(1775.8 2100.04)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path5 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path5_fill" transform="translate(1791.75 2105.71)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path6 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path6_fill" transform="translate(1815.77 2105.72)" fill="#4E4E4E" style="mix-blend-mode:normal;"/> -</g> -</g> -</g> -</g> -<g id="g" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path7 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path7_fill" transform="translate(1669.3 2093.31)" fill="#767677" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path8 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path8_fill" transform="translate(1639.74 2123.98)" fill="#F37726" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path9 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path9_fill" transform="translate(1639.73 2097.48)" fill="#F37726" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path10 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path10_fill" transform="translate(1639.8 2135.81)" fill="#989798" style="mix-blend-mode:normal;"/> -</g> -</g> -<g id="path" style="mix-blend-mode:normal;" figma:type="group"> -<g id="path11 fill" style="mix-blend-mode:normal;" figma:type="vector"> -<use xlink:href="#path11_fill" transform="translate(1638.36 2098.06)" fill="#6F7070" style="mix-blend-mode:normal;"/> -</g> -</g> -</g> -</g> -</g> -<defs> -<path id="path0_fill" d="M 5.62592 17.9276C 5.62592 23.0737 5.23342 24.7539 4.18673 25.9942C 3.04416 27.0501 1.54971 27.6341 0 27.6304L 0.392506 30.6916C 2.79452 30.7249 5.12302 29.8564 6.92556 28.255C 8.80086 26.3021 9.45504 23.6015 9.45504 19.4583L 9.45504 0L 5.6172 0L 5.6172 17.954L 5.62592 17.9276Z"/> -<path id="path1_fill" d="M 17.7413 15.6229C 17.7413 17.8397 17.7413 19.7925 17.9157 21.4727L 14.5576 21.4727L 14.3396 17.954L 14.2523 17.954C 13.5454 19.1838 12.5262 20.2013 11.2997 20.9017C 10.0732 21.6022 8.68377 21.9602 7.27445 21.9389C 3.95995 21.9389 0 20.074 0 12.5441L 0 0L 3.83784 0L 3.83784 11.9019C 3.83784 15.9836 5.05897 18.7281 8.53919 18.7281C 9.63092 18.708 10.6925 18.3634 11.5908 17.7374C 12.4892 17.1115 13.1844 16.2321 13.5894 15.2095C 13.8222 14.57 13.9404 13.8938 13.9383 13.2126L 13.9383 0.0175934L 17.7762 0.0175934L 17.7762 15.6229L 17.7413 15.6229Z"/> -<path id="path2_fill" d="M 0.174447 7.53632C 0.174447 4.79175 0.0872236 2.57499 0 0.498968L 3.44533 0.498968L 3.61978 4.17598L 3.707 4.17598C 4.46074 2.85853 5.55705 1.77379 6.87754 1.03893C 8.19802 0.304077 9.69248 -0.0529711 11.1995 0.0063534C 16.2934 0.0063534 20.1312 4.4047 20.1312 10.9142C 20.1312 18.6289 15.5171 22.4379 10.5366 22.4379C 9.25812 22.492 7.98766 22.2098 6.84994 21.6192C 5.71222 21.0285 4.74636 20.1496 4.04718 19.0688L 3.95995 19.0688L 3.95995 30.7244L 0.165725 30.7244L 0.165725 7.50113L 0.174447 7.53632ZM 3.96868 13.2542C 3.97869 13.7891 4.03708 14.3221 4.14312 14.8464C 4.45574 16.1467 5.19222 17.3035 6.23449 18.1313C 7.27677 18.9592 8.56446 19.4101 9.89116 19.4118C 13.9471 19.4118 16.2934 16.0427 16.2934 11.1254C 16.2934 6.82378 14.0692 3.14677 10.022 3.14677C 8.66089 3.18518 7.35158 3.68134 6.30219 4.55638C 5.25279 5.43143 4.52354 6.63513 4.23035 7.97615C 4.07662 8.49357 3.98869 9.02858 3.96868 9.56835L 3.96868 13.2454L 3.96868 13.2542Z"/> -<path id="path3_fill" d="M 4.16057 0L 8.7747 12.676C 9.25443 14.0923 9.77777 15.7813 10.1267 17.0744L 10.2139 17.0744C 10.6064 15.7901 11.0425 14.1451 11.5659 12.5969L 15.7526 0.00879669L 19.8085 0.00879669L 14.0604 15.3062C 11.3129 22.6603 9.44632 26.434 6.82961 28.7388C 5.50738 29.9791 3.88672 30.8494 2.12826 31.2634L 1.1688 27.9823C 2.39912 27.5689 3.53918 26.9208 4.52691 26.0734C 5.92259 24.8972 7.02752 23.4094 7.75418 21.7278C 7.90932 21.4374 8.01266 21.1218 8.05946 20.7954C 8.02501 20.4436 7.93674 20.0994 7.79779 19.7749L 0 0.00879669L 4.18673 0.00879669L 4.16057 0Z"/> -<path id="path4_fill" d="M 7.0215 0L 7.0215 6.15768L 12.5079 6.15768L 12.5079 9.13096L 7.0215 9.13096L 7.0215 20.6898C 7.0215 23.3288 7.7629 24.8594 9.89988 24.8594C 10.6496 24.8712 11.3975 24.7824 12.1241 24.5955L 12.2985 27.5248C 11.207 27.9125 10.0534 28.0915 8.89681 28.0526C 8.1298 28.1 7.36177 27.9782 6.64622 27.6956C 5.93068 27.413 5.28484 26.9765 4.75369 26.4164C 3.66339 25.2641 3.27089 23.3552 3.27089 20.8306L 3.27089 9.13096L 0 9.13096L 0 6.15768L 3.27089 6.15768L 3.27089 1.01162L 7.0215 0Z"/> -<path id="path5_fill" d="M 3.6285 11.9283C 3.71573 17.2063 7.03022 19.3791 10.8593 19.3791C 12.8612 19.4425 14.8527 19.0642 16.6946 18.2707L 17.3488 21.0593C 15.1419 21.994 12.7638 22.4467 10.3709 22.3876C 3.88145 22.3876 0 18.042 0 11.5676C 0 5.09328 3.75062 0 9.89116 0C 16.7731 0 18.6135 6.15768 18.6135 10.1074C 18.607 10.7165 18.5634 11.3246 18.4827 11.9283L 3.65467 11.9283L 3.6285 11.9283ZM 14.8716 9.13976C 14.9152 6.65909 13.8686 2.79735 9.55971 2.79735C 5.67826 2.79735 3.98612 6.43038 3.68084 9.13976L 14.8803 9.13976L 14.8716 9.13976Z"/> -<path id="path6_fill" d="M 0.174447 7.17854C 0.174447 4.65389 0.130835 2.48111 0 0.484261L 3.35811 0.484261L 3.48894 4.69787L 3.66339 4.69787C 4.62285 1.81256 6.93428 0.00044283 9.49865 0.00044283C 9.8663 -0.0049786 10.233 0.0394012 10.5889 0.132393L 10.5889 3.80941C 10.1593 3.71494 9.72029 3.67067 9.28059 3.67746C 6.57666 3.67746 4.66646 5.76227 4.14312 8.68277C 4.03516 9.28384 3.97681 9.89289 3.96867 10.5037L 3.96867 21.9394L 0.174447 21.9394L 0.174447 7.17854Z"/> -<path id="path7_fill" d="M 5.89353 2.844C 5.91889 3.43165 5.77085 4.01367 5.46815 4.51645C 5.16545 5.01922 4.72168 5.42015 4.19299 5.66851C 3.6643 5.91688 3.07444 6.00151 2.49805 5.91171C 1.92166 5.8219 1.38463 5.5617 0.954898 5.16401C 0.52517 4.76633 0.222056 4.24903 0.0839037 3.67757C -0.0542483 3.10611 -0.02123 2.50617 0.178781 1.95364C 0.378793 1.4011 0.736809 0.920817 1.20754 0.573538C 1.67826 0.226259 2.24055 0.0275919 2.82326 0.00267229C 3.60389 -0.0307115 4.36573 0.249789 4.94142 0.782551C 5.51711 1.31531 5.85956 2.05676 5.89353 2.844Z"/> -<path id="path8_fill" d="M 18.2646 7.13411C 10.4145 7.13411 3.55872 4.2576 0 0C 1.32539 3.8204 3.79556 7.13081 7.0686 9.47303C 10.3417 11.8152 14.2557 13.0734 18.269 13.0734C 22.2823 13.0734 26.1963 11.8152 29.4694 9.47303C 32.7424 7.13081 35.2126 3.8204 36.538 0C 32.9705 4.2576 26.1148 7.13411 18.2646 7.13411Z"/> -<path id="path9_fill" d="M 18.2733 5.93931C 26.1235 5.93931 32.9793 8.81583 36.538 13.0734C 35.2126 9.25303 32.7424 5.94262 29.4694 3.6004C 26.1963 1.25818 22.2823 0 18.269 0C 14.2557 0 10.3417 1.25818 7.0686 3.6004C 3.79556 5.94262 1.32539 9.25303 0 13.0734C 3.56745 8.82463 10.4232 5.93931 18.2733 5.93931Z"/> -<path id="path10_fill" d="M 7.42789 3.58338C 7.46008 4.3243 7.27355 5.05819 6.89193 5.69213C 6.51031 6.32607 5.95075 6.83156 5.28411 7.1446C 4.61747 7.45763 3.87371 7.56414 3.14702 7.45063C 2.42032 7.33712 1.74336 7.0087 1.20184 6.50695C 0.660328 6.0052 0.27861 5.35268 0.105017 4.63202C -0.0685757 3.91135 -0.0262361 3.15494 0.226675 2.45856C 0.479587 1.76217 0.931697 1.15713 1.52576 0.720033C 2.11983 0.282935 2.82914 0.0334395 3.56389 0.00313344C 4.54667 -0.0374033 5.50529 0.316706 6.22961 0.987835C 6.95393 1.65896 7.38484 2.59235 7.42789 3.58338L 7.42789 3.58338Z"/> -<path id="path11_fill" d="M 2.27471 4.39629C 1.84363 4.41508 1.41671 4.30445 1.04799 4.07843C 0.679268 3.8524 0.385328 3.52114 0.203371 3.12656C 0.0214136 2.73198 -0.0403798 2.29183 0.0258116 1.86181C 0.0920031 1.4318 0.283204 1.03126 0.575213 0.710883C 0.867222 0.39051 1.24691 0.164708 1.66622 0.0620592C 2.08553 -0.0405897 2.52561 -0.0154714 2.93076 0.134235C 3.33591 0.283941 3.68792 0.551505 3.94222 0.90306C 4.19652 1.25462 4.34169 1.67436 4.35935 2.10916C 4.38299 2.69107 4.17678 3.25869 3.78597 3.68746C 3.39516 4.11624 2.85166 4.37116 2.27471 4.39629L 2.27471 4.39629Z"/> -</defs> -</svg> diff --git a/docs/source/guides/getting-started/_static/logo-plotly.svg b/docs/source/guides/getting-started/_static/logo-plotly.svg deleted file mode 100644 index 3dd95459a..000000000 --- a/docs/source/guides/getting-started/_static/logo-plotly.svg +++ /dev/null @@ -1,11 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="147" height="46" viewBox="0 0 147 46"> - <g fill="none" fill-rule="evenodd"> - <path fill="#3F4F75" d="M41.909 46H3.66C1.64 46 0 44.307 0 42.22V3.78C0 1.692 1.64 0 3.661 0H41.91c2.022 0 3.661 1.692 3.661 3.78v38.44c0 2.087-1.64 3.78-3.661 3.78"/> - <path fill="#80CFBE" d="M11.383 17.956a1.263 1.263 0 0 1 .153.26 1.148 1.148 0 0 0-.433-.243c-.033-.072-.095-.133-.169-.147l-.022-.004a1.087 1.087 0 0 0-.065-.081c.191.052.377.124.536.215m-1.368 1.867c.117-.007.233-.03.344-.071.021.025.044.051.068.075a1.051 1.051 0 0 1-.436.027l.024-.03m.448-1.395a.62.62 0 0 0 .143.047.838.838 0 0 1 .007.107c-.081-.107-.228-.114-.337-.052l-.02-.08.164-.05c.013.01.027.02.043.028m-.717.143a1.102 1.102 0 0 0 .063.247c-.05-.055-.073-.137-.063-.247m1.412.65a35.186 35.186 0 0 0-.068-.133.803.803 0 0 0 .055-.122c.03.088.032.173.013.255m.718-1.553a3.586 3.586 0 0 0-.112-.074 2.06 2.06 0 0 0-.44-.431c-.718-.517-1.816-.567-2.338.252-.026.04-.05.082-.072.126a1.997 1.997 0 0 0-.652 1.15.251.251 0 0 0-.176.242c-.015.52.252.927.636 1.199.4.36.993.505 1.573.52.858.023 1.45-.447 1.716-1.07l.01-.013c.457-.6.51-1.433-.145-1.901M11.383 10.386a1.263 1.263 0 0 1 .153.26 1.142 1.142 0 0 0-.433-.243c-.033-.073-.095-.133-.169-.147l-.022-.005a1.08 1.08 0 0 0-.065-.08c.191.051.377.123.536.215m-1.368 1.867c.117-.007.233-.03.344-.072.021.026.044.051.068.075a1.02 1.02 0 0 1-.436.028l.024-.031m.448-1.394a.643.643 0 0 0 .143.046.833.833 0 0 1 .007.108c-.081-.107-.228-.115-.337-.052l-.02-.08.164-.05c.013.01.027.02.043.028M9.746 11c.005.033.01.066.019.099.012.05.027.099.044.149-.05-.055-.073-.137-.063-.248m1.412.65l-.068-.132c.022-.04.04-.08.055-.122.03.088.032.173.013.254m.718-1.552a2.91 2.91 0 0 0-.112-.075 2.048 2.048 0 0 0-.44-.43c-.718-.518-1.816-.567-2.338.252-.026.04-.05.082-.072.125a1.997 1.997 0 0 0-.652 1.15.251.251 0 0 0-.176.242c-.015.52.252.927.636 1.2.4.359.993.504 1.573.52.858.023 1.45-.447 1.716-1.07l.01-.013c.457-.6.51-1.433-.145-1.901M28.02 17.956a1.263 1.263 0 0 1 .153.26 1.148 1.148 0 0 0-.433-.243c-.033-.072-.095-.133-.169-.147l-.022-.004a1.087 1.087 0 0 0-.065-.081c.192.052.377.124.536.215m-1.368 1.867c.117-.007.233-.03.344-.071.022.025.044.051.068.075a1.051 1.051 0 0 1-.436.027l.024-.03m.448-1.395a.62.62 0 0 0 .144.047.838.838 0 0 1 .006.107c-.08-.107-.228-.114-.337-.052l-.02-.08.164-.05c.013.01.027.02.043.028m-.717.143a1.102 1.102 0 0 0 .063.247c-.05-.055-.073-.137-.063-.247m1.412.65a35.186 35.186 0 0 0-.068-.133.803.803 0 0 0 .056-.122c.029.088.031.173.012.255m.718-1.553a3.586 3.586 0 0 0-.111-.074 2.06 2.06 0 0 0-.441-.431c-.717-.517-1.816-.567-2.338.252-.025.04-.05.082-.072.126a1.994 1.994 0 0 0-.652 1.15.251.251 0 0 0-.176.242c-.015.52.252.927.636 1.199.4.36.994.505 1.573.52.858.023 1.45-.447 1.716-1.07l.01-.013c.457-.6.51-1.433-.145-1.901M28.02 10.386a1.263 1.263 0 0 1 .153.26 1.142 1.142 0 0 0-.433-.243c-.033-.073-.095-.133-.169-.147l-.022-.005a1.08 1.08 0 0 0-.065-.08c.192.051.377.123.536.215m-1.368 1.867c.117-.007.233-.03.344-.072.022.026.044.051.068.075a1.02 1.02 0 0 1-.436.028l.024-.031m.448-1.394a.643.643 0 0 0 .144.046.833.833 0 0 1 .006.108c-.08-.107-.228-.115-.337-.052l-.02-.08.164-.05c.013.01.027.02.043.028m-.717.142a1.66 1.66 0 0 0 .063.247c-.05-.054-.073-.136-.063-.247m1.412.65l-.068-.132c.022-.04.04-.08.056-.122.029.088.031.173.012.254m.718-1.552a2.91 2.91 0 0 0-.111-.075 2.048 2.048 0 0 0-.441-.43c-.717-.518-1.816-.567-2.338.252-.025.04-.05.082-.072.125a1.994 1.994 0 0 0-.652 1.15.251.251 0 0 0-.176.242c-.015.52.252.927.636 1.2.4.359.994.504 1.573.52.858.023 1.45-.447 1.716-1.07l.01-.013c.457-.6.51-1.433-.145-1.901"/> - <path fill="#FFF" d="M10.656 24.81c-1.116 0-2.02.92-2.02 2.056v8.221c0 1.135.904 2.055 2.02 2.055s2.02-.92 2.02-2.055v-8.221c0-1.135-.904-2.055-2.02-2.055M26.818 24.81c-1.116 0-2.02.92-2.02 2.056v8.221c0 1.135.904 2.055 2.02 2.055 1.115 0 2.02-.92 2.02-2.055v-8.221c0-1.135-.905-2.055-2.02-2.055M18.737 16.59c-1.116 0-2.02.92-2.02 2.054v16.443c0 1.135.904 2.055 2.02 2.055s2.02-.92 2.02-2.055V18.644c0-1.135-.904-2.055-2.02-2.055M34.899 16.59c-1.116 0-2.02.92-2.02 2.054v16.443c0 1.135.904 2.055 2.02 2.055 1.115 0 2.02-.92 2.02-2.055V18.644c0-1.135-.905-2.055-2.02-2.055"/> - <path fill="#80CFBE" d="M36.174 12.043c-.12.187-.263.316-.416.394.1-.156.198-.342.283-.546a2.23 2.23 0 0 0 .056-.126c.064-.058.1-.144.075-.227.002-.003.002-.007.003-.01.126-.094.215-.268.248-.455l.021-.033c.01.351-.078.703-.27 1.003m-2.656-.673a2.518 2.518 0 0 1-.009-.257c.079.295.198.584.32.837.07.188.16.386.274.53a1.13 1.13 0 0 1-.165-.127c-.269-.25-.386-.627-.42-.983m1.384-.405a3.724 3.724 0 0 1 .01-.564c.024.01.048.021.073.036l.039.027c.034.178.023.421-.122.5m1.823-.785a.407.407 0 0 0-.16-.31 2.293 2.293 0 0 0-.682-.717c-.383-.255-.815-.38-1.085-.047a3.714 3.714 0 0 0-.304-.015c-.062-.001-.119.004-.173.012-.322-.127-.666-.13-.815.282a1.743 1.743 0 0 0-.093.507.704.704 0 0 0-.158.164c-.313.449-.268 1.24-.144 1.74.21.852 1.117 1.522 1.903 1.27a.462.462 0 0 0 .184-.074c.42.079.878-.062 1.243-.507.545-.663.601-1.56.284-2.305M20.018 12.043c-.12.187-.264.316-.417.394.101-.156.198-.342.284-.546.02-.041.038-.083.055-.126.064-.058.1-.144.076-.227a.35.35 0 0 1 .003-.01c.126-.094.214-.268.248-.455l.02-.033c.01.351-.077.703-.27 1.003m-2.655-.673a2.518 2.518 0 0 1-.01-.257c.08.295.198.584.32.837.07.188.16.386.275.53a1.13 1.13 0 0 1-.165-.127c-.269-.25-.387-.627-.42-.983m1.384-.405a3.665 3.665 0 0 1 .01-.564c.023.01.047.021.072.036.014.008.026.017.04.027.033.178.022.421-.122.5m1.822-.785a.407.407 0 0 0-.159-.31 2.293 2.293 0 0 0-.683-.717c-.382-.255-.815-.38-1.084-.047a3.714 3.714 0 0 0-.304-.015c-.062-.001-.12.004-.173.012-.323-.127-.667-.13-.816.282a1.758 1.758 0 0 0-.093.507.704.704 0 0 0-.158.164c-.312.449-.268 1.24-.144 1.74.21.852 1.118 1.522 1.904 1.27a.462.462 0 0 0 .184-.074c.42.079.877-.062 1.243-.507.544-.663.6-1.56.283-2.305"/> - <g fill="#3F4F75"> - <path d="M74.759 32.856a1.862 1.862 0 0 1-1.852-1.873V12.26c0-1.035.829-1.873 1.852-1.873 1.022 0 1.851.838 1.851 1.873v18.723a1.862 1.862 0 0 1-1.851 1.873M113.644 32.856a1.862 1.862 0 0 1-1.851-1.873V12.26c0-1.035.829-1.873 1.851-1.873 1.023 0 1.852.838 1.852 1.873v18.723a1.862 1.862 0 0 1-1.852 1.873M65.5 25.026c0 2.256-1.658 4.085-3.704 4.085-2.045 0-3.703-1.83-3.703-4.085v-3.064c0-2.256 1.658-4.085 3.703-4.085 2.046 0 3.704 1.829 3.704 4.085v3.064zm-3.704-10.894a6.953 6.953 0 0 0-3.842 1.163 1.852 1.852 0 0 0-1.713-1.163 1.862 1.862 0 0 0-1.851 1.872v18.724c0 1.034.829 1.873 1.851 1.873a1.862 1.862 0 0 0 1.852-1.873V31.78a6.937 6.937 0 0 0 3.703 1.076c4.091 0 7.407-3.593 7.407-8.025v-2.675c0-4.431-3.316-8.024-7.407-8.024zM91.424 25.026c0 2.256-1.658 4.085-3.704 4.085-2.045 0-3.703-1.83-3.703-4.085v-3.064c0-2.256 1.658-4.085 3.703-4.085 2.046 0 3.704 1.829 3.704 4.085v3.064zM87.72 14.132c-4.09 0-7.406 3.593-7.406 8.024v2.675c0 4.432 3.316 8.025 7.406 8.025 4.091 0 7.407-3.593 7.407-8.025v-2.675c0-4.431-3.316-8.024-7.407-8.024zM106.238 29.111c-2.042 0-3.704-1.68-3.704-3.745v-7.49h3.704a1.862 1.862 0 0 0 1.851-1.872 1.862 1.862 0 0 0-1.851-1.872h-3.704v-1.873a1.862 1.862 0 0 0-1.851-1.872 1.862 1.862 0 0 0-1.852 1.872v13.107c0 4.13 3.323 7.49 7.407 7.49a1.862 1.862 0 0 0 1.851-1.873 1.862 1.862 0 0 0-1.851-1.872M132.247 14.284a1.844 1.844 0 0 0-2.432.983l-3.84 9.056-3.839-9.056a1.844 1.844 0 0 0-2.432-.983 1.88 1.88 0 0 0-.972 2.458l5.229 12.333-.622 1.467a3.71 3.71 0 0 1-3.335 2.308 1.864 1.864 0 0 0-1.809 1.917 1.862 1.862 0 0 0 1.85 1.828h.045a7.41 7.41 0 0 0 6.661-4.597l6.468-15.256a1.88 1.88 0 0 0-.972-2.458"/> - </g> - </g> -</svg> diff --git a/docs/source/guides/getting-started/_static/reactpy-in-jupyterlab.gif b/docs/source/guides/getting-started/_static/reactpy-in-jupyterlab.gif deleted file mode 100644 index b420ecd8c..000000000 Binary files a/docs/source/guides/getting-started/_static/reactpy-in-jupyterlab.gif and /dev/null differ diff --git a/docs/source/guides/getting-started/_static/shared-client-state-server-slider.gif b/docs/source/guides/getting-started/_static/shared-client-state-server-slider.gif deleted file mode 100644 index 61bb8295f..000000000 Binary files a/docs/source/guides/getting-started/_static/shared-client-state-server-slider.gif and /dev/null differ diff --git a/docs/source/guides/getting-started/index.rst b/docs/source/guides/getting-started/index.rst deleted file mode 100644 index dd210be60..000000000 --- a/docs/source/guides/getting-started/index.rst +++ /dev/null @@ -1,123 +0,0 @@ -Getting Started -=============== - -.. toctree:: - :hidden: - - installing-reactpy - running-reactpy - -.. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn - :color: info - :animate: fade-in - :open: - - .. grid:: 1 2 2 2 - - .. grid-item-card:: :octicon:`tools` Installing ReactPy - :link: installing-reactpy - :link-type: doc - - Learn how ReactPy can be installed in a variety of different ways - with - different web servers and even in different frameworks. - - .. grid-item-card:: :octicon:`play` Running ReactPy - :link: running-reactpy - :link-type: doc - - See how ReactPy can be run with a variety of different production servers or be - added to existing applications. - -The fastest way to get started with ReactPy is to try it out in a `Juptyer Notebook -<https://mybinder.org/v2/gh/reactive-python/reactpy-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb>`__. -If you want to use a Notebook to work through the examples shown in this documentation, -you'll need to replace calls to ``reactpy.run(App)`` with a line at the end of each cell -that constructs the ``App()`` in question. If that doesn't make sense, the introductory -notebook linked below will demonstrate how to do this: - -.. card:: - :link: https://mybinder.org/v2/gh/reactive-python/reactpy-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb - - .. image:: _static/reactpy-in-jupyterlab.gif - :scale: 72% - :align: center - - -Section 1: Installing ReactPy ------------------------------ - -The next fastest option is to install ReactPy along with a supported server (like -``starlette``) with ``pip``: - -.. code-block:: bash - - pip install "reactpy[starlette]" - -To check that everything is working you can run the sample application: - -.. code-block:: bash - - python -c "import reactpy; reactpy.run(reactpy.sample.SampleApp)" - -.. note:: - - This launches a simple development server which is good enough for testing, but - probably not what you want to use in production. When deploying in production, - there's a number of different ways of :ref:`running ReactPy <Section 2: Running ReactPy>`. - -You should then see a few log messages: - -.. code-block:: text - - 2022-03-27T11:58:59-0700 | WARNING | You are running a development server. Change this before deploying in production! - 2022-03-27T11:58:59-0700 | INFO | Running with 'Starlette' at http://127.0.0.1:8000 - -The second log message includes a URL indicating where you should go to view the app. -That will usually be http://127.0.0.1:8000. Once you go to that URL you should see -something like this: - -.. card:: - - .. reactpy-view:: _examples/sample_app - -If you get a ``RuntimeError`` similar to the following: - -.. code-block:: text - - Found none of the following builtin server implementations... - -Then be sure you run ``pip install "reactpy[starlette]"`` instead of just ``reactpy``. For -anything else, report your issue in ReactPy's :discussion-type:`discussion forum -<problem>`. - -.. card:: - :link: installing-reactpy - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn how ReactPy can be installed in a variety of different ways - with different web - servers and even in different frameworks. - - -Section 2: Running ReactPy --------------------------- - -Once you've :ref:`installed ReactPy <Installing ReactPy>`, you'll want to learn how to run an -application. Throughout most of the examples in this documentation, you'll see the -:func:`~reactpy.backend.utils.run` function used. While it's convenient tool for -development it shouldn't be used in production settings - it's slow, and could leak -secrets through debug log messages. - -.. reactpy:: _examples/hello_world - -.. card:: - :link: running-reactpy - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - See how ReactPy can be run with a variety of different production servers or be - added to existing applications. diff --git a/docs/source/guides/getting-started/installing-reactpy.rst b/docs/source/guides/getting-started/installing-reactpy.rst deleted file mode 100644 index 0b2ffc28a..000000000 --- a/docs/source/guides/getting-started/installing-reactpy.rst +++ /dev/null @@ -1,121 +0,0 @@ -Installing ReactPy -================== - -You will typically ``pip`` install ReactPy to alongside one of it's natively supported -backends. For example, if we want to run ReactPy using the `Starlette -<https://www.starlette.io/>`__ backend you would run - -.. code-block:: bash - - pip install "reactpy[starlette]" - -If you want to install a "pure" version of ReactPy **without a backend implementation** -you can do so without any installation extras. You might do this if you wanted to -:ref:`use a custom backend <using a custom backend>` or if you wanted to manually pin -the dependencies for your chosen backend: - -.. code-block:: bash - - pip install reactpy - - -Native Backends ---------------- - -ReactPy includes built-in support for a variety backend implementations. To install the -required dependencies for each you should substitute ``starlette`` from the ``pip -install`` command above with one of the options below: - -- ``fastapi`` - https://fastapi.tiangolo.com -- ``flask`` - https://palletsprojects.com/p/flask/ -- ``sanic`` - https://sanicframework.org -- ``starlette`` - https://www.starlette.io/ -- ``tornado`` - https://www.tornadoweb.org/en/stable/ - -If you need to, you can install more than one option by separating them with commas: - -.. code-block:: bash - - pip install "reactpy[fastapi,flask,sanic,starlette,tornado]" - -Once this is complete you should be able to :ref:`run ReactPy <Running ReactPy>` with your -chosen implementation. - - -Other Backends --------------- - -While ReactPy can run in a variety of contexts, sometimes frameworks require extra work in -order to integrate with them. In these cases, the ReactPy team distributes bindings for -those frameworks as separate Python packages. For documentation on how to install and -run ReactPy in these supported frameworks, follow the links below: - -.. raw:: html - - <style> - .card-logo-image { - display: flex; - justify-content: center; - align-content: center; - padding: 10px; - background-color: var(--color-background-primary); - border: 2px solid var(--color-background-border); - } - - .transparent-text-color { - color: transparent; - } - </style> - -.. role:: transparent-text-color - -.. We add transparent-text-color to the text so it's not visible, but it's still -.. searchable. - -.. grid:: 3 - - .. grid-item-card:: - :link: https://github.com/reactive-python/reactpy-django - :img-background: _static/logo-django.svg - :class-card: card-logo-image - - :transparent-text-color:`Django` - - .. grid-item-card:: - :link: https://github.com/reactive-python/reactpy-jupyter - :img-background: _static/logo-jupyter.svg - :class-card: card-logo-image - - :transparent-text-color:`Jupyter` - - .. grid-item-card:: - :link: https://github.com/reactive-python/reactpy-dash - :img-background: _static/logo-plotly.svg - :class-card: card-logo-image - - :transparent-text-color:`Plotly Dash` - - -For Development ---------------- - -If you want to contribute to the development of ReactPy or modify it, you'll want to -install a development version of ReactPy. This involves cloning the repository where ReactPy's -source is maintained, and setting up a :ref:`development environment`. From there you'll -be able to modifying ReactPy's source code and :ref:`run its tests <Running The Tests>` to -ensure the modifications you've made are backwards compatible. If you want to add a new -feature to ReactPy you should write your own test that validates its behavior. - -If you have questions about how to modify ReactPy or help with its development, be sure to -:discussion:`start a discussion <new?category=question>`. The ReactPy team are always -excited to :ref:`welcome <everyone can contribute>` new contributions and contributors -of all kinds - -.. card:: - :link: /about/contributor-guide - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn more about how to contribute to the development of ReactPy. diff --git a/docs/source/guides/getting-started/running-reactpy.rst b/docs/source/guides/getting-started/running-reactpy.rst deleted file mode 100644 index 8abbd574f..000000000 --- a/docs/source/guides/getting-started/running-reactpy.rst +++ /dev/null @@ -1,221 +0,0 @@ -Running ReactPy -=============== - -The simplest way to run ReactPy is with the :func:`~reactpy.backend.utils.run` function. This -is the method you'll see used throughout this documentation. However, this executes your -application using a development server which is great for testing, but probably not what -if you're :ref:`deploying in production <Running ReactPy in Production>`. Below are some -more robust and performant ways of running ReactPy with various supported servers. - - -Running ReactPy in Production ------------------------------ - -The first thing you'll need to do if you want to run ReactPy in production is choose a -backend implementation and follow its documentation on how to create and run an -application. This is the backend :ref:`you probably chose <Native Backends>` when -installing ReactPy. Then you'll need to configure that application with an ReactPy view. We -show the basics of how to set up, and then run, each supported backend below, but all -implementations will follow a pattern similar to the following: - -.. code-block:: - - from my_chosen_backend import Application - - from reactpy import component, html - from reactpy.backend.my_chosen_backend import configure - - - @component - def HelloWorld(): - return html.h1("Hello, world!") - - - app = Application() - configure(app, HelloWorld) - -You'll then run this ``app`` using an `ASGI <https://asgi.readthedocs.io/en/latest/>`__ -or `WSGI <https://wsgi.readthedocs.io/>`__ server from the command line. - - -Running with `FastAPI <https://fastapi.tiangolo.com>`__ -....................................................... - -.. reactpy:: _examples/run_fastapi - -Then assuming you put this in ``main.py``, you can run the ``app`` using the `Uvicorn -<https://www.uvicorn.org/>`__ ASGI server: - -.. code-block:: bash - - uvicorn main:app - - -Running with `Flask <https://palletsprojects.com/p/flask/>`__ -............................................................. - -.. reactpy:: _examples/run_flask - -Then assuming you put this in ``main.py``, you can run the ``app`` using the `Gunicorn -<https://gunicorn.org/>`__ WSGI server: - -.. code-block:: bash - - gunicorn main:app - - -Running with `Sanic <https://sanicframework.org>`__ -................................................... - -.. reactpy:: _examples/run_sanic - -Then assuming you put this in ``main.py``, you can run the ``app`` using Sanic's builtin -server: - -.. code-block:: bash - - sanic main.app - - -Running with `Starlette <https://www.starlette.io/>`__ -...................................................... - -.. reactpy:: _examples/run_starlette - -Then assuming you put this in ``main.py``, you can run the application using the -`Uvicorn <https://www.uvicorn.org/>`__ ASGI server: - -.. code-block:: bash - - uvicorn main:app - - -Running with `Tornado <https://www.tornadoweb.org/en/stable/>`__ -................................................................ - -.. reactpy:: _examples/run_tornado - -Tornado is run using it's own builtin server rather than an external WSGI or ASGI -server. - - -Running ReactPy in Debug Mode ------------------------------ - -ReactPy provides a debug mode that is turned off by default. This can be enabled when you -run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. - -.. tab-set:: - - .. tab-item:: Unix Shell - - .. code-block:: - - export REACTPY_DEBUG_MODE=1 - python my_reactpy_app.py - - .. tab-item:: Command Prompt - - .. code-block:: text - - set REACTPY_DEBUG_MODE=1 - python my_reactpy_app.py - - .. tab-item:: PowerShell - - .. code-block:: powershell - - $env:REACTPY_DEBUG_MODE = "1" - python my_reactpy_app.py - -.. danger:: - - Leave debug mode off in production! - -Among other things, running in this mode: - -- Turns on debug log messages -- Adds checks to ensure the :ref:`VDOM` spec is adhered to -- Displays error messages that occur within your app - -Errors will be displayed where the uppermost component is located in the view: - -.. reactpy:: _examples/debug_error_example - - -Backend Configuration Options ------------------------------ - -ReactPy's various backend implementations come with ``Options`` that can be passed to their -respective ``configure()`` functions in the following way: - -.. code-block:: - - from reactpy.backend.<implementation> import configure, Options - - configure(app, MyComponent, Options(...)) - -To learn more read about the options for your chosen backend ``<implementation>``: - -- :class:`reactpy.backend.fastapi.Options` -- :class:`reactpy.backend.flask.Options` -- :class:`reactpy.backend.sanic.Options` -- :class:`reactpy.backend.starlette.Options` -- :class:`reactpy.backend.tornado.Options` - - -Embed in an Existing Webpage ----------------------------- - -ReactPy provides a Javascript client called ``@reactpy/client`` that can be used to embed -ReactPy views within an existing applications. This is actually how the interactive -examples throughout this documentation have been created. You can try this out by -embedding one the examples from this documentation into your own webpage: - -.. tab-set:: - - .. tab-item:: HTML - - .. literalinclude:: _static/embed-doc-ex.html - :language: html - - .. tab-item:: ▶️ Result - - .. raw:: html - :file: _static/embed-doc-ex.html - -.. note:: - - For more information on how to use the client see the :ref:`Javascript API` - reference. Or if you need to, your can :ref:`write your own backend implementation - <writing your own backend>`. - -As mentioned though, this is connecting to the server that is hosting this -documentation. If you want to connect to a view from your own server, you'll need to -change the URL above to one you provide. One way to do this might be to add to an -existing application. Another would be to run ReactPy in an adjacent web server instance -that you coordinate with something like `NGINX <https://www.nginx.com/>`__. For the sake -of simplicity, we'll assume you do something similar to the following in an existing -Python app: - -.. tab-set:: - - .. tab-item:: main.py - - .. literalinclude:: _static/embed-reactpy-view/main.py - :language: python - - .. tab-item:: index.html - - .. literalinclude:: _static/embed-reactpy-view/index.html - :language: html - -After running ``python main.py``, you should be able to navigate to -``http://127.0.0.1:8000/index.html`` and see: - -.. card:: - :text-align: center - - .. image:: _static/embed-reactpy-view/screenshot.png - :width: 500px - diff --git a/docs/source/guides/managing-state/combining-contexts-and-reducers/index.rst b/docs/source/guides/managing-state/combining-contexts-and-reducers/index.rst deleted file mode 100644 index b9f274f0a..000000000 --- a/docs/source/guides/managing-state/combining-contexts-and-reducers/index.rst +++ /dev/null @@ -1,6 +0,0 @@ -Combining Contexts and Reducers 🚧 -================================== - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/managing-state/deeply-sharing-state-with-contexts/index.rst b/docs/source/guides/managing-state/deeply-sharing-state-with-contexts/index.rst deleted file mode 100644 index 4a00caa48..000000000 --- a/docs/source/guides/managing-state/deeply-sharing-state-with-contexts/index.rst +++ /dev/null @@ -1,6 +0,0 @@ -Deeply Sharing State with Contexts 🚧 -===================================== - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/managing-state/how-to-structure-state/index.rst b/docs/source/guides/managing-state/how-to-structure-state/index.rst deleted file mode 100644 index 5092370a5..000000000 --- a/docs/source/guides/managing-state/how-to-structure-state/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _Structuring Your State: - -How to Structure State 🚧 -========================= - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/managing-state/index.rst b/docs/source/guides/managing-state/index.rst deleted file mode 100644 index 0578bafdd..000000000 --- a/docs/source/guides/managing-state/index.rst +++ /dev/null @@ -1,127 +0,0 @@ -Managing State -============== - -.. toctree:: - :hidden: - - how-to-structure-state/index - sharing-component-state/index - when-and-how-to-reset-state/index - simplifying-updates-with-reducers/index - deeply-sharing-state-with-contexts/index - combining-contexts-and-reducers/index - -.. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn - :color: info - :animate: fade-in - :open: - - .. grid:: 1 2 2 2 - - .. grid-item-card:: :octicon:`organization` How to Structure State - :link: how-to-structure-state/index - :link-type: doc - - Make it easy to reason about your application with strategies for organizing - state. - - .. grid-item-card:: :octicon:`link` Sharing Component State - :link: sharing-component-state/index - :link-type: doc - - Allow components to vary vary together, by lifting state into common - parents. - - .. grid-item-card:: :octicon:`light-bulb` When and How to Reset State - :link: when-and-how-to-reset-state/index - :link-type: doc - - Control if and how state is preserved by understanding it's relationship to - the "UI tree". - - .. grid-item-card:: :octicon:`plug` Simplifying Updates with Reducers - :link: simplifying-updates-with-reducers/index - :link-type: doc - - Consolidate state update logic outside your component in a single function, - called a “reducer". - - .. grid-item-card:: :octicon:`broadcast` Deeply Sharing State with Contexts - :link: deeply-sharing-state-with-contexts/index - :link-type: doc - - Instead of passing shared state down deep component trees, bring state into - "contexts" instead. - - .. grid-item-card:: :octicon:`rocket` Combining Contexts and Reducers - :link: combining-contexts-and-reducers/index - :link-type: doc - - You can combine reducers and context together to manage state of a complex - screen. - - -Section 1: How to Structure State ---------------------------------- - -.. note:: - - Under construction 🚧 - - -Section 2: Shared Component State ---------------------------------- - -Sometimes, you want the state of two components to always change together. To do it, -remove state from both of them, move it to their closest common parent, and then pass it -down to them via props. This is known as “lifting state up”, and it’s one of the most -common things you will do writing code with ReactPy. - -In the example below the search input and the list of elements below share the same -state, the state represents the food name. Note how the component ``Table`` gets called -at each change of state. The component is observing the state and reacting to state -changes automatically, just like it would do in React. - -.. reactpy:: sharing-component-state/_examples/synced_inputs - -.. card:: - :link: sharing-component-state/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Allow components to vary vary together, by lifting state into common parents. - - -Section 3: When and How to Reset State --------------------------------------- - -.. note:: - - Under construction 🚧 - - -Section 4: Simplifying Updates with Reducers --------------------------------------------- - -.. note:: - - Under construction 🚧 - - -Section 5: Deeply Sharing State with Contexts ---------------------------------------------- - -.. note:: - - Under construction 🚧 - - - -Section 6: Combining Contexts and Reducers ------------------------------------------- - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/data.json b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/data.json deleted file mode 100644 index f977fe9a7..000000000 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/data.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "name": "Sushi", - "description": "Sushi is a traditional Japanese dish of prepared vinegared rice" - }, - { - "name": "Dal", - "description": "The most common way of preparing dal is in the form of a soup to which onions, tomatoes and various spices may be added" - }, - { - "name": "Pierogi", - "description": "Pierogi are filled dumplings made by wrapping unleavened dough around a savoury or sweet filling and cooking in boiling water" - }, - { - "name": "Shish Kebab", - "description": "Shish kebab is a popular meal of skewered and grilled cubes of meat" - }, - { - "name": "Dim sum", - "description": "Dim sum is a large range of small dishes that Cantonese people traditionally enjoy in restaurants for breakfast and lunch" - } -] diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py deleted file mode 100644 index ca68aedcb..000000000 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py +++ /dev/null @@ -1,44 +0,0 @@ -import json -from pathlib import Path - -from reactpy import component, hooks, html, run - -HERE = Path(__file__) -DATA_PATH = HERE.parent / "data.json" -food_data = json.loads(DATA_PATH.read_text()) - - -@component -def FilterableList(): - value, set_value = hooks.use_state("") - return html.p(Search(value, set_value), html.hr(), Table(value, set_value)) - - -@component -def Search(value, set_value): - def handle_change(event): - set_value(event["target"]["value"]) - - return html.label( - "Search by Food Name: ", - html.input({"value": value, "on_change": handle_change}), - ) - - -@component -def Table(value, set_value): - rows = [] - for row in food_data: - name = html.td(row["name"]) - descr = html.td(row["description"]) - tr = html.tr(name, descr, value) - if not value: - rows.append(tr) - elif value.lower() in row["name"].lower(): - rows.append(tr) - headers = html.tr(html.td(html.b("name")), html.td(html.b("description"))) - table = html.table(html.thead(headers), html.tbody(rows)) - return table - - -run(FilterableList) diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py deleted file mode 100644 index e8bcdf333..000000000 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py +++ /dev/null @@ -1,23 +0,0 @@ -from reactpy import component, hooks, html, run - - -@component -def SyncedInputs(): - value, set_value = hooks.use_state("") - return html.p( - Input("First input", value, set_value), - Input("Second input", value, set_value), - ) - - -@component -def Input(label, value, set_value): - def handle_change(event): - set_value(event["target"]["value"]) - - return html.label( - label + " ", html.input({"value": value, "on_change": handle_change}) - ) - - -run(SyncedInputs) diff --git a/docs/source/guides/managing-state/sharing-component-state/index.rst b/docs/source/guides/managing-state/sharing-component-state/index.rst deleted file mode 100644 index 54b61335a..000000000 --- a/docs/source/guides/managing-state/sharing-component-state/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -Sharing Component State -======================= - -.. note:: - - Parts of this document are still under construction 🚧 - -Sometimes, you want the state of two components to always change together. To do it, -remove state from both of them, move it to their closest common parent, and then pass it -down to them via props. This is known as “lifting state up”, and it’s one of the most -common things you will do writing code with ReactPy. - - -Synced Inputs -------------- - -In the code below the two input boxes are synchronized, this happens because they share -state. The state is shared via the parent component ``SyncedInputs``. Check the ``value`` -and ``set_value`` variables. - -.. reactpy:: _examples/synced_inputs - - -Filterable List ----------------- - -In the example below the search input and the list of elements below share the -same state, the state represents the food name. - -Note how the component ``Table`` gets called at each change of state. The -component is observing the state and reacting to state changes automatically, -just like it would do in React. - -.. reactpy:: _examples/filterable_list - -.. note:: - - Try typing a food name in the search bar. diff --git a/docs/source/guides/managing-state/simplifying-updates-with-reducers/index.rst b/docs/source/guides/managing-state/simplifying-updates-with-reducers/index.rst deleted file mode 100644 index 08fce5a69..000000000 --- a/docs/source/guides/managing-state/simplifying-updates-with-reducers/index.rst +++ /dev/null @@ -1,6 +0,0 @@ -Simplifying Updates with Reducers 🚧 -==================================== - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/managing-state/when-and-how-to-reset-state/index.rst b/docs/source/guides/managing-state/when-and-how-to-reset-state/index.rst deleted file mode 100644 index 6a96f4b30..000000000 --- a/docs/source/guides/managing-state/when-and-how-to-reset-state/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _When to Reset State: - -When and How to Reset State 🚧 -============================== - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/_static/idom-flow-diagram.svg b/docs/source/guides/understanding-reactpy/_static/idom-flow-diagram.svg deleted file mode 100644 index 9077913ca..000000000 --- a/docs/source/guides/understanding-reactpy/_static/idom-flow-diagram.svg +++ /dev/null @@ -1,383 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.1" - width="680" - height="580" - viewBox="-0.5 -0.5 680 580" - content="<mxfile host="app.diagrams.net" modified="2020-09-07T18:34:20.858Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36" etag="IvUE9xI9CxZQnD7O0sJm" version="13.6.6" type="device"><diagram id="3GUrj3vU2Wc3lj3yRkW7" name="Page-1">7Zpdb9owFIZ/DZetkrgEuCy027R1XSWkddqdiU8Sqw5mjvnar5+d2CEhFEpbPkZBlUqO7WP7+D0PdpIG6iWzzwKP4u+cAGt4Dpk10E3D89wrz2voP4fMc0sLdXJDJCgxlRaGPv0Lxtg01jElkFYqSs6ZpKOqMeDDIQSyYsNC8Gm1WshZtdcRjqBm6AeY1a2PlMg4t7abzsL+BWgU255dx5Qk2FY2hjTGhE9LJnTbQD3Bucy/JbMeMB08G5e83adnSouBCRjKlzTAv+WFQ760viXebfT49/4J/cIXfu5lgtnYTLjh+Uz564ZcuVUBw0Fe4P8Z65F2e3wsKAhVdA/ThVlPUM5Zta72cZFma3qtKqjRzsot/Mj8zzocWMMdnvOxtGY1ocFyVWXLh2fNXqV7T/DxkICet6uKpzGV0B/lE5kqmSpbLBNmikPKWI8zLrK2iGBoh4Gyp1LwJyiV+EEbBmFRYuWAihFMQEiYPbs8brHoKluAJyDFXFUxDTzX6MQkim8upwvVFckUlxRn62Ej9KjwvNCC+mLksIU0WkcojRuajrAMYt3Jx5ZHcX0wfbSPUB+PoL46fRCTs0LQwQnSOUKF9BiF4dofl4H4iGppHpwn7iqgLEUahuRab+rUVcBwmtKgGtxqqJr1cINLmtBaFe6O30LYL4ILpLYv3BjaUujs/rAcOWsTwLCkk6r7VeE0PTxwmmXLbGml5naVllYkVUkUgGlV3hBucFRcW0cSiwhkzVG2usW037DgG/mwddJvQZQ8l7/2f9yrsge9qXhpumtp3OGBOuhUtIcZjYZamEomqkPU1flJ1Uni2hQklBDtoytAzQAPMn+Ouh7pCGcxb3YbzRtlYdp9FwdPUcaWkkw/ZZ91DDAnIeN/cf4oC3hNAj5LDOfSayNUUYz7NkEbLxduq9qEh2EKO5GcHdyZMdsxxmu9E2PQ8s/Hjhlj1/fMmNcwRg/R3JLxnJ0wZ8MuZYfMQXtjDjoz5zXMcd9rX+PteV/jXR2eOT9vfnw/s2ZVIp42a1Ztqd+bNWEIfrDyyEpanYHj/AesQWgDIl7KmmVHNWjtmDW2+8Pub1TJ7aR6W+VMnCId90+c/QEH7eNAdYLAqZ2DXguc2sls18A5igPVGTjr0vGkgbOP09QJAqd2c/e1wKndbt41cI7gNHUGzvp0PGXgXG3cYW+rpkcYpDx4gkJM2z9xzNb+gadUUq5lI3J+FPpiEMqVoEtjPNI+klmk39e6TNRAxqPLBAv9LxgLNu+KbD4VYNpXl7SICE7j7GGns/J2U/bR1ahQisqHp1ijR/Cukn35o07fvku2TLANJO5s/6RTXS5e6MoVuHgtDt3+Aw==</diagram></mxfile>" - id="svg4818" - sodipodi:docname="reactpy-flow-diagram.svg" - inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"> - <metadata - id="metadata4822"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <sodipodi:namedview - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1" - objecttolerance="10" - gridtolerance="10" - guidetolerance="10" - inkscape:pageopacity="0" - inkscape:pageshadow="2" - inkscape:window-width="1920" - inkscape:window-height="1016" - id="namedview4820" - showgrid="true" - inkscape:zoom="1.1798897" - inkscape:cx="295.35891" - inkscape:cy="286.00243" - inkscape:window-x="0" - inkscape:window-y="27" - inkscape:window-maximized="1" - inkscape:current-layer="svg4818" - showguides="false" - inkscape:snap-object-midpoints="false" - inkscape:snap-bbox="true" - inkscape:snap-center="false" - inkscape:snap-text-baseline="true"> - <inkscape:grid - type="xygrid" - id="grid4962" - spacingx="10" - spacingy="10" /> - </sodipodi:namedview> - <defs - id="defs4706"> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="marker5531" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5529" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:0.96568627" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="marker5527" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5525" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:0.96568627" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="marker5523" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5521" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="Arrow2Mend" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5224" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Mend" - orient="auto" - refY="0" - refX="0" - id="Arrow1Mend" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5206" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" - transform="matrix(-0.4,0,0,-0.4,-4,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="marker5513" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5511" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" - transform="matrix(-0.8,0,0,-0.8,-10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="marker5509" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5507" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" - transform="matrix(-0.8,0,0,-0.8,-10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="Arrow1Lend" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5200" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:0.96568627" - transform="matrix(-0.8,0,0,-0.8,-10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lstart" - orient="auto" - refY="0" - refX="0" - id="Arrow1Lstart" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5197" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:0.96568627" - transform="matrix(0.8,0,0,0.8,10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="Arrow1Lend-9" - style="overflow:visible" - inkscape:isstock="true"> - <path - inkscape:connector-curvature="0" - id="path5200-3" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:0.96568627" - transform="matrix(-0.8,0,0,-0.8,-10,0)" /> - </marker> - </defs> - <rect - style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:5.73294544;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect1077" - width="680" - height="580" - x="-0.5" - y="-0.5" /> - <rect - style="opacity:1;fill:#9fa8da;fill-opacity:0.99607843;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect4966" - width="600" - height="200" - x="39.5" - y="39.5" - ry="20" /> - <g - id="g5077" - transform="translate(30,40)"> - <g - id="g5106" - style="opacity:1"> - <rect - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect5002-9" - width="200" - height="100" - x="59.5" - y="49.5" - ry="20" /> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="109.5" - y="109.5" - id="text5072"><tspan - sodipodi:role="line" - id="tspan5070" - x="109.5" - y="109.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">layout</tspan></text> - </g> - </g> - <g - transform="translate(330,40)" - id="g5077-0"> - <rect - ry="20" - y="49.5" - x="59.5" - height="100" - width="200" - id="rect5002-9-9" - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> - <text - id="text5072-2" - y="109.5" - x="89.5" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - xml:space="preserve"><tspan - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1" - y="109.5" - x="89.5" - id="tspan5070-5" - sodipodi:role="line">component</tspan></text> - </g> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none" - x="49.5" - y="69.5" - id="text5110"><tspan - sodipodi:role="line" - id="tspan5108" - x="49.5" - y="69.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:24px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">server</tspan></text> - <rect - style="opacity:1;fill:#9fa8da;fill-opacity:0.99607843;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect4966-5" - width="600" - height="200" - x="39.5" - y="339.5" - ry="20" /> - <g - transform="translate(180,350)" - id="g5077-0-2"> - <rect - ry="20" - y="49.5" - x="59.5" - height="100" - width="200" - id="rect5002-9-9-4" - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> - <text - id="text5072-2-7" - y="109.5" - x="129.5" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - xml:space="preserve"><tspan - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1" - y="109.5" - x="129.5" - id="tspan5070-5-7" - sodipodi:role="line">view</tspan></text> - </g> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none" - x="49.817509" - y="370.17709" - id="text5110-5"><tspan - sodipodi:role="line" - id="tspan5108-4" - x="49.817509" - y="370.17709" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:24px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">client</tspan></text> - <path - style="display:inline;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#marker5523)" - d="m 359.5,399.5 130,-210" - id="path5179" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <path - style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend)" - d="m 389.5,139.5 h -100" - id="path5181" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <path - style="display:inline;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:3.29999995;stroke-dasharray:none;stroke-opacity:0.96568627;marker-end:url(#marker5527)" - d="m 189.5,189.5 130,210" - id="path5183" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:0.99516913;stroke:none" - x="439.5" - y="299.5" - id="text5535"><tspan - sodipodi:role="line" - id="tspan5533" - x="439.5" - y="299.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#000000;fill-opacity:0.99516913">event</tspan></text> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="89.5" - y="299.5" - id="text5535-8"><tspan - sodipodi:role="line" - id="tspan5533-0" - x="89.5" - y="299.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#000000;fill-opacity:1">VDOM diff</tspan></text> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:0.98067633;stroke:none" - x="309.5" - y="189.5" - id="text5535-8-2"><tspan - sodipodi:role="line" - id="tspan5533-0-1" - x="309.5" - y="189.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#000000;fill-opacity:0.98067633">VDOM</tspan></text> -</svg> diff --git a/docs/source/guides/understanding-reactpy/_static/live-examples-in-docs.gif b/docs/source/guides/understanding-reactpy/_static/live-examples-in-docs.gif deleted file mode 100644 index 96a04d68b..000000000 Binary files a/docs/source/guides/understanding-reactpy/_static/live-examples-in-docs.gif and /dev/null differ diff --git a/docs/source/guides/understanding-reactpy/_static/mvc-flow-diagram.svg b/docs/source/guides/understanding-reactpy/_static/mvc-flow-diagram.svg deleted file mode 100644 index a1acbc2cb..000000000 --- a/docs/source/guides/understanding-reactpy/_static/mvc-flow-diagram.svg +++ /dev/null @@ -1,425 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.1" - width="680" - height="580" - viewBox="-0.5 -0.5 680 580" - content="<mxfile host="app.diagrams.net" modified="2020-09-07T18:34:20.858Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36" etag="IvUE9xI9CxZQnD7O0sJm" version="13.6.6" type="device"><diagram id="3GUrj3vU2Wc3lj3yRkW7" name="Page-1">7Zpdb9owFIZ/DZetkrgEuCy027R1XSWkddqdiU8Sqw5mjvnar5+d2CEhFEpbPkZBlUqO7WP7+D0PdpIG6iWzzwKP4u+cAGt4Dpk10E3D89wrz2voP4fMc0sLdXJDJCgxlRaGPv0Lxtg01jElkFYqSs6ZpKOqMeDDIQSyYsNC8Gm1WshZtdcRjqBm6AeY1a2PlMg4t7abzsL+BWgU255dx5Qk2FY2hjTGhE9LJnTbQD3Bucy/JbMeMB08G5e83adnSouBCRjKlzTAv+WFQ760viXebfT49/4J/cIXfu5lgtnYTLjh+Uz564ZcuVUBw0Fe4P8Z65F2e3wsKAhVdA/ThVlPUM5Zta72cZFma3qtKqjRzsot/Mj8zzocWMMdnvOxtGY1ocFyVWXLh2fNXqV7T/DxkICet6uKpzGV0B/lE5kqmSpbLBNmikPKWI8zLrK2iGBoh4Gyp1LwJyiV+EEbBmFRYuWAihFMQEiYPbs8brHoKluAJyDFXFUxDTzX6MQkim8upwvVFckUlxRn62Ej9KjwvNCC+mLksIU0WkcojRuajrAMYt3Jx5ZHcX0wfbSPUB+PoL46fRCTs0LQwQnSOUKF9BiF4dofl4H4iGppHpwn7iqgLEUahuRab+rUVcBwmtKgGtxqqJr1cINLmtBaFe6O30LYL4ILpLYv3BjaUujs/rAcOWsTwLCkk6r7VeE0PTxwmmXLbGml5naVllYkVUkUgGlV3hBucFRcW0cSiwhkzVG2usW037DgG/mwddJvQZQ8l7/2f9yrsge9qXhpumtp3OGBOuhUtIcZjYZamEomqkPU1flJ1Uni2hQklBDtoytAzQAPMn+Ouh7pCGcxb3YbzRtlYdp9FwdPUcaWkkw/ZZ91DDAnIeN/cf4oC3hNAj5LDOfSayNUUYz7NkEbLxduq9qEh2EKO5GcHdyZMdsxxmu9E2PQ8s/Hjhlj1/fMmNcwRg/R3JLxnJ0wZ8MuZYfMQXtjDjoz5zXMcd9rX+PteV/jXR2eOT9vfnw/s2ZVIp42a1Ztqd+bNWEIfrDyyEpanYHj/AesQWgDIl7KmmVHNWjtmDW2+8Pub1TJ7aR6W+VMnCId90+c/QEH7eNAdYLAqZ2DXguc2sls18A5igPVGTjr0vGkgbOP09QJAqd2c/e1wKndbt41cI7gNHUGzvp0PGXgXG3cYW+rpkcYpDx4gkJM2z9xzNb+gadUUq5lI3J+FPpiEMqVoEtjPNI+klmk39e6TNRAxqPLBAv9LxgLNu+KbD4VYNpXl7SICE7j7GGns/J2U/bR1ahQisqHp1ijR/Cukn35o07fvku2TLANJO5s/6RTXS5e6MoVuHgtDt3+Aw==</diagram></mxfile>" - id="svg4818" - sodipodi:docname="mvc-flow-diagram.svg" - inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"> - <metadata - id="metadata4822"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <sodipodi:namedview - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1" - objecttolerance="10" - gridtolerance="10" - guidetolerance="10" - inkscape:pageopacity="0" - inkscape:pageshadow="2" - inkscape:window-width="1920" - inkscape:window-height="1016" - id="namedview4820" - showgrid="true" - inkscape:zoom="1.1798897" - inkscape:cx="230.94611" - inkscape:cy="244.6615" - inkscape:window-x="0" - inkscape:window-y="27" - inkscape:window-maximized="1" - inkscape:current-layer="svg4818" - showguides="false" - inkscape:snap-object-midpoints="false" - inkscape:snap-bbox="true" - inkscape:snap-center="false" - inkscape:snap-text-baseline="true"> - <inkscape:grid - type="xygrid" - id="grid4962" - spacingx="10" - spacingy="10" /> - </sodipodi:namedview> - <defs - id="defs4706"> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="marker5531" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5529" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:0.96568627" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="marker5527" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5525" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:0.96568627" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="marker5523" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5521" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow2Mend" - orient="auto" - refY="0" - refX="0" - id="Arrow2Mend" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5224" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1" - d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z" - transform="scale(-0.6)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Mend" - orient="auto" - refY="0" - refX="0" - id="Arrow1Mend" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5206" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" - transform="matrix(-0.4,0,0,-0.4,-4,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="marker5513" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5511" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" - transform="matrix(-0.8,0,0,-0.8,-10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="marker5509" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5507" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" - transform="matrix(-0.8,0,0,-0.8,-10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="Arrow1Lend" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5200" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:0.96568627" - transform="matrix(-0.8,0,0,-0.8,-10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lstart" - orient="auto" - refY="0" - refX="0" - id="Arrow1Lstart" - style="overflow:visible" - inkscape:isstock="true"> - <path - id="path5197" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:0.96568627" - transform="matrix(0.8,0,0,0.8,10,0)" - inkscape:connector-curvature="0" /> - </marker> - <marker - inkscape:stockid="Arrow1Lend" - orient="auto" - refY="0" - refX="0" - id="Arrow1Lend-9" - style="overflow:visible" - inkscape:isstock="true"> - <path - inkscape:connector-curvature="0" - id="path5200-3" - d="M 0,0 5,-5 -12.5,0 5,5 Z" - style="fill:#000000;fill-opacity:0.96568627;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:0.96568627" - transform="matrix(-0.8,0,0,-0.8,-10,0)" /> - </marker> - </defs> - <rect - style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect1940" - width="680" - height="580" - x="-0.5" - y="-0.5" /> - <rect - style="opacity:1;fill:#9fa8da;fill-opacity:0.99607843;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect4966" - width="600" - height="200" - x="39.5" - y="39.5" - ry="20" /> - <g - id="g5077" - transform="translate(30,40)"> - <g - id="g5106"> - <rect - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect5002-9" - width="200" - height="100" - x="59.5" - y="49.5" - ry="20" /> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="123.48081" - y="109.51302" - id="text5072"><tspan - sodipodi:role="line" - id="tspan5070" - x="123.48081" - y="109.51302" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">model</tspan></text> - </g> - </g> - <g - transform="translate(330,40)" - id="g5077-0"> - <rect - ry="20" - y="49.5" - x="59.5" - height="100" - width="200" - id="rect5002-9-9" - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> - <text - id="text5072-2" - y="109.51302" - x="78.464844" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - xml:space="preserve"><tspan - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1" - y="109.51302" - x="78.464844" - id="tspan5070-5" - sodipodi:role="line">controller</tspan></text> - </g> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none" - x="49.5" - y="69.5" - id="text5110"><tspan - sodipodi:role="line" - id="tspan5108" - x="49.5" - y="69.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:24px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">server</tspan></text> - <rect - style="opacity:1;fill:#9fa8da;fill-opacity:0.99607843;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect4966-5" - width="600" - height="200" - x="39.5" - y="339.5" - ry="20" /> - <g - id="g5077-9" - transform="translate(30,340)"> - <g - id="g5106-4"> - <rect - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" - id="rect5002-9-6" - width="200" - height="100" - x="59.5" - y="49.5" - ry="20" /> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="123.48081" - y="109.51302" - id="text5072-9"><tspan - sodipodi:role="line" - id="tspan5070-2" - x="123.48081" - y="109.51302" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">model</tspan></text> - </g> - </g> - <g - transform="translate(330,340)" - id="g5077-0-2"> - <rect - ry="20" - y="49.5" - x="59.5" - height="100" - width="200" - id="rect5002-9-9-4" - style="opacity:1;fill:#4052b5;fill-opacity:1;stroke:none;stroke-width:13;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> - <text - id="text5072-2-7" - y="109.5" - x="129.5" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - xml:space="preserve"><tspan - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1" - y="109.5" - x="129.5" - id="tspan5070-5-7" - sodipodi:role="line">view</tspan></text> - </g> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none" - x="49.817509" - y="370.17709" - id="text5110-5"><tspan - sodipodi:role="line" - id="tspan5108-4" - x="49.817509" - y="370.17709" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:24px;font-family:monospace;-inkscape-font-specification:monospace;fill:#ffffff;fill-opacity:1">client</tspan></text> - <path - style="display:inline;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#marker5523)" - d="m 489.5,389.5 v -200" - id="path5179" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <path - style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-end:url(#Arrow2Mend)" - d="m 389.5,139.5 h -100" - id="path5181" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <path - style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:3.29999995;stroke-dasharray:none;stroke-opacity:0.96568627;marker-end:url(#marker5527)" - d="m 189.5,189.5 v 200" - id="path5183" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <path - style="display:inline;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:3.29999995;stroke-dasharray:none;stroke-opacity:0.96568627;marker-end:url(#marker5531)" - d="m 289.5,439.5 h 100" - id="path5183-6" - inkscape:connector-type="polyline" - inkscape:connector-curvature="0" /> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="499.5" - y="299.5" - id="text5535"><tspan - sodipodi:role="line" - id="tspan5533" - x="499.5" - y="299.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace">event</tspan></text> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="199.5" - y="299.5" - id="text5535-8"><tspan - sodipodi:role="line" - id="tspan5533-0" - x="199.5" - y="299.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace">sync</tspan></text> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="289.5" - y="189.5" - id="text5535-8-2"><tspan - sodipodi:role="line" - id="tspan5533-0-1" - x="289.5" - y="189.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace">change</tspan></text> - <text - xml:space="preserve" - style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none" - x="289.5" - y="489.5" - id="text5535-8-2-0"><tspan - sodipodi:role="line" - id="tspan5533-0-1-5" - x="289.5" - y="489.5" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:26.66666603px;font-family:monospace;-inkscape-font-specification:monospace">render</tspan></text> -</svg> diff --git a/docs/source/guides/understanding-reactpy/_static/npm-download-trends.png b/docs/source/guides/understanding-reactpy/_static/npm-download-trends.png deleted file mode 100644 index cf5140b0d..000000000 Binary files a/docs/source/guides/understanding-reactpy/_static/npm-download-trends.png and /dev/null differ diff --git a/docs/source/guides/understanding-reactpy/index.rst b/docs/source/guides/understanding-reactpy/index.rst deleted file mode 100644 index 3e0b2ab72..000000000 --- a/docs/source/guides/understanding-reactpy/index.rst +++ /dev/null @@ -1,17 +0,0 @@ -Understanding ReactPy -===================== - -.. toctree:: - :hidden: - - representing-html - what-are-components - the-rendering-pipeline - why-reactpy-needs-keys - the-rendering-process - layout-render-servers - writing-tests - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/layout-render-servers.rst b/docs/source/guides/understanding-reactpy/layout-render-servers.rst deleted file mode 100644 index 9a7cceb54..000000000 --- a/docs/source/guides/understanding-reactpy/layout-render-servers.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _Layout Render Servers: - -Layout Render Servers 🚧 -======================== - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/representing-html.rst b/docs/source/guides/understanding-reactpy/representing-html.rst deleted file mode 100644 index c2f32ebd9..000000000 --- a/docs/source/guides/understanding-reactpy/representing-html.rst +++ /dev/null @@ -1,76 +0,0 @@ -.. _Representing HTML: - -Representing HTML 🚧 -==================== - -.. note:: - - Under construction 🚧 - -We've already discussed how to construct HTML with ReactPy in a :ref:`previous section <HTML -with ReactPy>`, but we skimmed over the question of the data structure we use to represent -it. Let's reconsider the examples from before - on the top is some HTML and on the -bottom is the corresponding code to create it in ReactPy: - -.. code-block:: html - - <div> - <h1>My Todo List</h1> - <ul> - <li>Build a cool new app</li> - <li>Share it with the world!</li> - </ul> - </div> - -.. testcode:: - - from reactpy import html - - layout = html.div( - html.h1("My Todo List"), - html.ul( - html.li("Build a cool new app"), - html.li("Share it with the world!"), - ) - ) - -Since we've captured our HTML into out the ``layout`` variable, we can inspect what it -contains. And, as it turns out, it holds a dictionary. Printing it produces the -following output: - -.. testsetup:: - - from pprint import pprint - print = lambda *args, **kwargs: pprint(*args, **kwargs, sort_dicts=False) - -.. testcode:: - - assert layout == { - 'tagName': 'div', - 'children': [ - { - 'tagName': 'h1', - 'children': ['My Todo List'] - }, - { - 'tagName': 'ul', - 'children': [ - {'tagName': 'li', 'children': ['Build a cool new app']}, - {'tagName': 'li', 'children': ['Share it with the world!']} - ] - } - ] - } - -This may look complicated, but let's take a moment to consider what's going on here. We -have a series of nested dictionaries that, in some way, represents the HTML structure -given above. If we look at their contents we should see a common form. Each has a -``tagName`` key which contains, as the name would suggest, the tag name of an HTML -element. Then within the ``children`` key is a list that either contains strings or -other dictionaries that represent HTML elements. - -What we're seeing here is called a "virtual document object model" or :ref:`VDOM`. This -is just a fancy way of saying we have a representation of the document object model or -`DOM -<https://en.wikipedia.org/wiki/Document_Object_Model#:~:text=The%20Document%20Object%20Model%20(DOM,document%20with%20a%20logical%20tree.&text=Nodes%20can%20have%20event%20handlers%20attached%20to%20them.>`__ -that is not the actual DOM. diff --git a/docs/source/guides/understanding-reactpy/the-rendering-pipeline.rst b/docs/source/guides/understanding-reactpy/the-rendering-pipeline.rst deleted file mode 100644 index cdde27f08..000000000 --- a/docs/source/guides/understanding-reactpy/the-rendering-pipeline.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _The Rendering Pipeline: - -The Rendering Pipeline 🚧 -========================= - -.. talk about layouts and dispatchers - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/the-rendering-process.rst b/docs/source/guides/understanding-reactpy/the-rendering-process.rst deleted file mode 100644 index 00215a887..000000000 --- a/docs/source/guides/understanding-reactpy/the-rendering-process.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _The Rendering Process: - -The Rendering Process 🚧 -======================== - -.. refer to https://beta.reactjs.org/learn/render-and-commit - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/what-are-components.rst b/docs/source/guides/understanding-reactpy/what-are-components.rst deleted file mode 100644 index 4c22dda13..000000000 --- a/docs/source/guides/understanding-reactpy/what-are-components.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _What Are Components: - -What Are Components? 🚧 -======================= - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/why-reactpy-needs-keys.rst b/docs/source/guides/understanding-reactpy/why-reactpy-needs-keys.rst deleted file mode 100644 index e570b8f41..000000000 --- a/docs/source/guides/understanding-reactpy/why-reactpy-needs-keys.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _Why ReactPy Needs Keys: - -Why ReactPy Needs Keys 🚧 -========================= - -.. note:: - - Under construction 🚧 diff --git a/docs/source/guides/understanding-reactpy/writing-tests.rst b/docs/source/guides/understanding-reactpy/writing-tests.rst deleted file mode 100644 index ffac27df6..000000000 --- a/docs/source/guides/understanding-reactpy/writing-tests.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _Writing Tests: - -Writing Tests 🚧 -================ - -.. note:: - - Under construction 🚧 diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 8b21160f6..000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,206 +0,0 @@ -.. card:: - - This documentation is still under construction 🚧. We welcome your `feedback - <https://github.com/reactive-python/reactpy/discussions>`__! - - -ReactPy -======= - -.. toctree:: - :hidden: - :caption: Guides - - guides/getting-started/index - guides/creating-interfaces/index - guides/adding-interactivity/index - guides/managing-state/index - guides/escape-hatches/index - guides/understanding-reactpy/index - -.. toctree:: - :hidden: - :caption: Reference - - reference/browser-events - reference/html-attributes - reference/hooks-api - _auto/apis - reference/javascript-api - reference/specifications - -.. toctree:: - :hidden: - :caption: About - - about/changelog - about/contributor-guide - about/credits-and-licenses - Source Code <https://github.com/reactive-python/reactpy> - Community <https://github.com/reactive-python/reactpy/discussions> - -ReactPy is a library for building user interfaces in Python without Javascript. ReactPy -interfaces are made from :ref:`components <Your First Components>` which look and behave -similarly to those found in `ReactJS <https://reactjs.org/>`__. Designed with simplicity -in mind, ReactPy can be used by those without web development experience while also -being powerful enough to grow with your ambitions. - - -At a Glance ------------ - -To get a rough idea of how to write apps in ReactPy, take a look at the tiny `"hello world" -<https://en.wikipedia.org/wiki/%22Hello,_World!%22_program>`__ application below: - -.. reactpy:: guides/getting-started/_examples/hello_world - -.. hint:: - - Try clicking the **🚀 result** tab to see what this displays! - -So what exactly does this code do? First, it imports a few tools from ``reactpy`` that will -get used to describe and execute an application. Then, we create an ``App`` function -which will define the content the application displays. Specifically, it displays a kind -of HTML element called an ``h1`` `section heading -<https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements>`__. -Importantly though, a ``@component`` decorator has been applied to the ``App`` function -to turn it into a :ref:`component <Your First Components>`. Finally, we :ref:`run -<Running ReactPy>` a development web server by passing the ``App`` component to the -``run()`` function. - -.. note:: - - See :ref:`Running ReactPy in Production` to learn how to use a production-grade server - to run ReactPy. - - -Learning ReactPy ----------------- - -This documentation is broken up into chapters and sections that introduce you to -concepts step by step with detailed explanations and lots of examples. You should feel -free to dive into any content that seems interesting. While each chapter assumes -knowledge from those that came before, when you encounter a concept you're unfamiliar -with you should look for links that will help direct you to the place where it was -originally taught. - - -Chapter 1 - :ref:`Getting Started` ------------------------------------ - -If you want to follow along with examples in the sections that follow, you'll want to -start here so you can :ref:`install ReactPy <Installing ReactPy>`. This section also contains -more detailed information about how to :ref:`run ReactPy <Running ReactPy>` in different -contexts. For example, if you want to embed ReactPy into an existing application, or run -ReactPy within a Jupyter Notebook, this is where you can learn how to do those things. - -.. grid:: 1 2 2 2 - - .. grid-item:: - - .. image:: _static/install-and-run-reactpy.gif - - .. grid-item:: - - .. image:: guides/getting-started/_static/reactpy-in-jupyterlab.gif - -.. card:: - :link: guides/getting-started/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Install ReactPy and run it in a variety of different ways - with different web servers - and frameworks. You'll even embed ReactPy into an existing app. - - -Chapter 2 - :ref:`Creating Interfaces` --------------------------------------- - -ReactPy is a Python package for making user interfaces (UI). These interfaces are built -from small elements of functionality like buttons text and images. ReactPy allows you to -combine these elements into reusable :ref:`"components" <your first components>`. In the -sections that follow you'll learn how these UI elements are created and organized into -components. Then, you'll use this knowledge to create interfaces from raw data: - -.. reactpy:: guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys - -.. card:: - :link: guides/creating-interfaces/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn to construct user interfaces from basic HTML elements and reusable components. - - -Chapter 3 - :ref:`Adding Interactivity` ---------------------------------------- - -Components often need to change what’s on the screen as a result of an interaction. For -example, typing into the form should update the input field, clicking a “Comment” button -should bring up a text input field, clicking “Buy” should put a product in the shopping -cart. Components need to “remember” things like the current input value, the current -image, the shopping cart. In ReactPy, this kind of component-specific memory is created and -updated with a "hook" called ``use_state()`` that creates a **state variable** and -**state setter** respectively: - -.. reactpy:: guides/adding-interactivity/components-with-state/_examples/adding_state_variable - -In ReactPy, ``use_state``, as well as any other function whose name starts with ``use``, is -called a "hook". These are special functions that should only be called while ReactPy is -:ref:`rendering <the rendering process>`. They let you "hook into" the different -capabilities of ReactPy's components of which ``use_state`` is just one (well get into the -other :ref:`later <managing state>`). - -.. card:: - :link: guides/adding-interactivity/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Learn how user interfaces can be made to respond to user interaction in real-time. - - -Chapter 4 - :ref:`Managing State` ---------------------------------- - -.. card:: - :link: guides/managing-state/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Under construction 🚧 - - - -Chapter 5 - :ref:`Escape Hatches` ---------------------------------- - -.. card:: - :link: guides/escape-hatches/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Under construction 🚧 - - -Chapter 6 - :ref:`Understanding ReactPy` ----------------------------------------- - -.. card:: - :link: guides/escape-hatches/index - :link-type: doc - - :octicon:`book` Read More - ^^^^^^^^^^^^^^^^^^^^^^^^^ - - Under construction 🚧 - diff --git a/docs/source/reference/_examples/character_movement/main.py b/docs/source/reference/_examples/character_movement/main.py deleted file mode 100644 index 9545b0c0a..000000000 --- a/docs/source/reference/_examples/character_movement/main.py +++ /dev/null @@ -1,73 +0,0 @@ -from pathlib import Path -from typing import NamedTuple - -from reactpy import component, html, run, use_state -from reactpy.widgets import image - -HERE = Path(__file__) -CHARACTER_IMAGE = (HERE.parent / "static" / "bunny.png").read_bytes() - - -class Position(NamedTuple): - x: int - y: int - angle: int - - -def rotate(degrees): - return lambda old_position: Position( - old_position.x, - old_position.y, - old_position.angle + degrees, - ) - - -def translate(x=0, y=0): - return lambda old_position: Position( - old_position.x + x, - old_position.y + y, - old_position.angle, - ) - - -@component -def Scene(): - position, set_position = use_state(Position(100, 100, 0)) - - return html.div( - {"style": {"width": "225px"}}, - html.div( - { - "style": { - "width": "200px", - "height": "200px", - "background_color": "slategray", - } - }, - image( - "png", - CHARACTER_IMAGE, - { - "style": { - "position": "relative", - "left": f"{position.x}px", - "top": f"{position.y}.px", - "transform": f"rotate({position.angle}deg) scale(2, 2)", - } - }, - ), - ), - html.button( - {"on_click": lambda e: set_position(translate(x=-10))}, "Move Left" - ), - html.button( - {"on_click": lambda e: set_position(translate(x=10))}, "Move Right" - ), - html.button({"on_click": lambda e: set_position(translate(y=-10))}, "Move Up"), - html.button({"on_click": lambda e: set_position(translate(y=10))}, "Move Down"), - html.button({"on_click": lambda e: set_position(rotate(-30))}, "Rotate Left"), - html.button({"on_click": lambda e: set_position(rotate(30))}, "Rotate Right"), - ) - - -run(Scene) diff --git a/docs/source/reference/_examples/character_movement/static/bunny.png b/docs/source/reference/_examples/character_movement/static/bunny.png deleted file mode 100644 index ce1f989c5..000000000 Binary files a/docs/source/reference/_examples/character_movement/static/bunny.png and /dev/null differ diff --git a/docs/source/reference/_examples/click_count.py b/docs/source/reference/_examples/click_count.py deleted file mode 100644 index 3ee2c89c5..000000000 --- a/docs/source/reference/_examples/click_count.py +++ /dev/null @@ -1,13 +0,0 @@ -import reactpy - - -@reactpy.component -def ClickCount(): - count, set_count = reactpy.hooks.use_state(0) - - return reactpy.html.button( - {"on_click": lambda event: set_count(count + 1)}, [f"Click count: {count}"] - ) - - -reactpy.run(ClickCount) diff --git a/docs/source/reference/_examples/material_ui_switch.py b/docs/source/reference/_examples/material_ui_switch.py deleted file mode 100644 index 704ae3145..000000000 --- a/docs/source/reference/_examples/material_ui_switch.py +++ /dev/null @@ -1,22 +0,0 @@ -import reactpy - -mui = reactpy.web.module_from_template("react", "@material-ui/core@^5.0", fallback="⌛") -Switch = reactpy.web.export(mui, "Switch") - - -@reactpy.component -def DayNightSwitch(): - checked, set_checked = reactpy.hooks.use_state(False) - - return reactpy.html.div( - Switch( - { - "checked": checked, - "onChange": lambda event, checked: set_checked(checked), - } - ), - "🌞" if checked else "🌚", - ) - - -reactpy.run(DayNightSwitch) diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py deleted file mode 100644 index 5c4d616fe..000000000 --- a/docs/source/reference/_examples/matplotlib_plot.py +++ /dev/null @@ -1,85 +0,0 @@ -from io import BytesIO - -import matplotlib.pyplot as plt - -import reactpy -from reactpy.widgets import image - - -@reactpy.component -def PolynomialPlot(): - coefficients, set_coefficients = reactpy.hooks.use_state([0]) - - x = list(linspace(-1, 1, 50)) - y = [polynomial(value, coefficients) for value in x] - - return reactpy.html.div( - plot(f"{len(coefficients)} Term Polynomial", x, y), - ExpandableNumberInputs(coefficients, set_coefficients), - ) - - -@reactpy.component -def ExpandableNumberInputs(values, set_values): - inputs = [] - for i in range(len(values)): - - def set_value_at_index(event, index=i): - new_value = float(event["target"]["value"] or 0) - set_values(values[:index] + [new_value] + values[index + 1 :]) - - inputs.append(poly_coef_input(i + 1, set_value_at_index)) - - def add_input(): - set_values([*values, 0]) - - def del_input(): - set_values(values[:-1]) - - return reactpy.html.div( - reactpy.html.div( - "add/remove term:", - reactpy.html.button({"on_click": lambda event: add_input()}, "+"), - reactpy.html.button({"on_click": lambda event: del_input()}, "-"), - ), - inputs, - ) - - -def plot(title, x, y): - fig, axes = plt.subplots() - axes.plot(x, y) - axes.set_title(title) - buffer = BytesIO() - fig.savefig(buffer, format="png") - plt.close(fig) - return image("png", buffer.getvalue()) - - -def poly_coef_input(index, callback): - return reactpy.html.div( - {"style": {"margin-top": "5px"}, "key": index}, - reactpy.html.label( - "C", - reactpy.html.sub(index), - " x X", - reactpy.html.sup(index), - ), - reactpy.html.input({"type": "number", "on_change": callback}), - ) - - -def polynomial(x, coefficients): - return sum(c * (x ** (i + 1)) for i, c in enumerate(coefficients)) - - -def linspace(start, stop, n): - if n == 1: - yield stop - return - h = (stop - start) / (n - 1) - for i in range(n): - yield start + h * i - - -reactpy.run(PolynomialPlot) diff --git a/docs/source/reference/_examples/network_graph.py b/docs/source/reference/_examples/network_graph.py deleted file mode 100644 index 79b1092f3..000000000 --- a/docs/source/reference/_examples/network_graph.py +++ /dev/null @@ -1,40 +0,0 @@ -import random - -import reactpy - -react_cytoscapejs = reactpy.web.module_from_template( - "react", - "react-cytoscapejs", - fallback="⌛", -) -Cytoscape = reactpy.web.export(react_cytoscapejs, "default") - - -@reactpy.component -def RandomNetworkGraph(): - return Cytoscape( - { - "style": {"width": "100%", "height": "200px"}, - "elements": random_network(20), - "layout": {"name": "cose"}, - } - ) - - -def random_network(number_of_nodes): - conns = [] - nodes = [{"data": {"id": 0, "label": 0}}] - - for src_node_id in range(1, number_of_nodes + 1): - tgt_node = random.choice(nodes) - src_node = {"data": {"id": src_node_id, "label": src_node_id}} - - new_conn = {"data": {"source": src_node_id, "target": tgt_node["data"]["id"]}} - - nodes.append(src_node) - conns.append(new_conn) - - return nodes + conns - - -reactpy.run(RandomNetworkGraph) diff --git a/docs/source/reference/_examples/pigeon_maps.py b/docs/source/reference/_examples/pigeon_maps.py deleted file mode 100644 index 1ddf04fdc..000000000 --- a/docs/source/reference/_examples/pigeon_maps.py +++ /dev/null @@ -1,46 +0,0 @@ -import reactpy - -pigeon_maps = reactpy.web.module_from_template("react", "pigeon-maps", fallback="⌛") -Map, Marker = reactpy.web.export(pigeon_maps, ["Map", "Marker"]) - - -@reactpy.component -def MapWithMarkers(): - marker_anchor, add_marker_anchor, remove_marker_anchor = use_set() - - markers = [ - Marker( - { - "anchor": anchor, - "onClick": lambda event, a=anchor: remove_marker_anchor(a), - }, - key=str(anchor), - ) - for anchor in marker_anchor - ] - - return Map( - { - "defaultCenter": (37.774, -122.419), - "defaultZoom": 12, - "height": "300px", - "metaWheelZoom": True, - "onClick": lambda event: add_marker_anchor(tuple(event["latLng"])), - }, - markers, - ) - - -def use_set(initial_value=None): - values, set_values = reactpy.hooks.use_state(initial_value or set()) - - def add_value(lat_lon): - set_values(values.union({lat_lon})) - - def remove_value(lat_lon): - set_values(values.difference({lat_lon})) - - return values, add_value, remove_value - - -reactpy.run(MapWithMarkers) diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py deleted file mode 100644 index 66913fc84..000000000 --- a/docs/source/reference/_examples/simple_dashboard.py +++ /dev/null @@ -1,102 +0,0 @@ -import asyncio -import random -import time - -import reactpy -from reactpy.widgets import Input - -victory = reactpy.web.module_from_template( - "react", - "victory-line", - fallback="⌛", - # not usually required (see issue #461 for more info) - unmount_before_update=True, -) -VictoryLine = reactpy.web.export(victory, "VictoryLine") - - -@reactpy.component -def RandomWalk(): - mu = reactpy.hooks.use_ref(0) - sigma = reactpy.hooks.use_ref(1) - - return reactpy.html.div( - RandomWalkGraph(mu, sigma), - reactpy.html.style( - """ - .number-input-container {margin-bottom: 20px} - .number-input-container input {width: 48%;float: left} - .number-input-container input + input {margin-left: 4%} - """ - ), - NumberInput( - "Mean", - mu.current, - mu.set_current, - (-1, 1, 0.01), - ), - NumberInput( - "Standard Deviation", - sigma.current, - sigma.set_current, - (0, 1, 0.01), - ), - ) - - -@reactpy.component -def RandomWalkGraph(mu, sigma): - interval = use_interval(0.5) - data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50) - - @reactpy.hooks.use_effect - async def animate(): - await interval - last_data_point = data[-1] - next_data_point = { - "x": last_data_point["x"] + 1, - "y": last_data_point["y"] + random.gauss(mu.current, sigma.current), - } - set_data(data[1:] + [next_data_point]) - - return VictoryLine( - { - "data": data, - "style": { - "parent": {"width": "100%"}, - "data": {"stroke": "royalblue"}, - }, - } - ) - - -@reactpy.component -def NumberInput(label, value, set_value_callback, domain): - minimum, maximum, step = domain - attrs = {"min": minimum, "max": maximum, "step": step} - - value, set_value = reactpy.hooks.use_state(value) - - def update_value(value): - set_value(value) - set_value_callback(value) - - return reactpy.html.fieldset( - {"class_name": "number-input-container"}, - reactpy.html.legend({"style": {"font-size": "medium"}}, label), - Input(update_value, "number", value, attributes=attrs, cast=float), - Input(update_value, "range", value, attributes=attrs, cast=float), - ) - - -def use_interval(rate): - usage_time = reactpy.hooks.use_ref(time.time()) - - async def interval() -> None: - await asyncio.sleep(rate - (time.time() - usage_time.current)) - usage_time.current = time.time() - - return asyncio.ensure_future(interval()) - - -reactpy.run(RandomWalk) diff --git a/docs/source/reference/_examples/slideshow.py b/docs/source/reference/_examples/slideshow.py deleted file mode 100644 index b490b3feb..000000000 --- a/docs/source/reference/_examples/slideshow.py +++ /dev/null @@ -1,20 +0,0 @@ -import reactpy - - -@reactpy.component -def Slideshow(): - index, set_index = reactpy.hooks.use_state(0) - - def next_image(event): - set_index(index + 1) - - return reactpy.html.img( - { - "src": f"https://picsum.photos/id/{index}/800/300", - "style": {"cursor": "pointer"}, - "on_click": next_image, - } - ) - - -reactpy.run(Slideshow) diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py deleted file mode 100644 index 36916410e..000000000 --- a/docs/source/reference/_examples/snake_game.py +++ /dev/null @@ -1,188 +0,0 @@ -import asyncio -import enum -import random -import time - -import reactpy - - -class GameState(enum.Enum): - init = 0 - lost = 1 - won = 2 - play = 3 - - -@reactpy.component -def GameView(): - game_state, set_game_state = reactpy.hooks.use_state(GameState.init) - - if game_state == GameState.play: - return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state) - - start_button = reactpy.html.button( - {"on_click": lambda event: set_game_state(GameState.play)}, "Start" - ) - - if game_state == GameState.won: - menu = reactpy.html.div(reactpy.html.h3("You won!"), start_button) - elif game_state == GameState.lost: - menu = reactpy.html.div(reactpy.html.h3("You lost"), start_button) - else: - menu = reactpy.html.div(reactpy.html.h3("Click to play"), start_button) - - menu_style = reactpy.html.style( - """ - .snake-game-menu h3 { - margin-top: 0px !important; - } - """ - ) - - return reactpy.html.div({"class_name": "snake-game-menu"}, menu_style, menu) - - -class Direction(enum.Enum): - ArrowUp = (0, -1) - ArrowLeft = (-1, 0) - ArrowDown = (0, 1) - ArrowRight = (1, 0) - - -@reactpy.component -def GameLoop(grid_size, block_scale, set_game_state): - # we `use_ref` here to capture the latest direction press without any delay - direction = reactpy.hooks.use_ref(Direction.ArrowRight.value) - # capture the last direction of travel that was rendered - last_direction = direction.current - - snake, set_snake = reactpy.hooks.use_state( - [(grid_size // 2 - 1, grid_size // 2 - 1)] - ) - food, set_food = use_snake_food(grid_size, snake) - - grid = create_grid(grid_size, block_scale) - - @reactpy.event(prevent_default=True) - def on_direction_change(event): - if hasattr(Direction, event["key"]): - maybe_new_direction = Direction[event["key"]].value - direction_vector_sum = tuple( - map(sum, zip(last_direction, maybe_new_direction)) - ) - if direction_vector_sum != (0, 0): - direction.current = maybe_new_direction - - grid_wrapper = reactpy.html.div({"on_key_down": on_direction_change}, grid) - - assign_grid_block_color(grid, food, "blue") - - for location in snake: - assign_grid_block_color(grid, location, "white") - - new_game_state = None - if snake[-1] in snake[:-1]: - assign_grid_block_color(grid, snake[-1], "red") - new_game_state = GameState.lost - elif len(snake) == grid_size**2: - assign_grid_block_color(grid, snake[-1], "yellow") - new_game_state = GameState.won - - interval = use_interval(0.5) - - @reactpy.hooks.use_effect - async def animate(): - if new_game_state is not None: - await asyncio.sleep(1) - set_game_state(new_game_state) - return - - await interval - - new_snake_head = ( - # grid wraps due to mod op here - (snake[-1][0] + direction.current[0]) % grid_size, - (snake[-1][1] + direction.current[1]) % grid_size, - ) - - if snake[-1] == food: - set_food() - new_snake = [*snake, new_snake_head] - else: - new_snake = snake[1:] + [new_snake_head] - - set_snake(new_snake) - - return grid_wrapper - - -def use_snake_food(grid_size, current_snake): - grid_points = {(x, y) for x in range(grid_size) for y in range(grid_size)} - points_not_in_snake = grid_points.difference(current_snake) - - food, _set_food = reactpy.hooks.use_state(current_snake[-1]) - - def set_food(): - _set_food(random.choice(list(points_not_in_snake))) - - return food, set_food - - -def use_interval(rate): - usage_time = reactpy.hooks.use_ref(time.time()) - - async def interval() -> None: - await asyncio.sleep(rate - (time.time() - usage_time.current)) - usage_time.current = time.time() - - return asyncio.ensure_future(interval()) - - -def create_grid(grid_size, block_scale): - return reactpy.html.div( - { - "style": { - "height": f"{block_scale * grid_size}px", - "width": f"{block_scale * grid_size}px", - "cursor": "pointer", - "display": "grid", - "grid-gap": 0, - "grid-template-columns": f"repeat({grid_size}, {block_scale}px)", - "grid-template-rows": f"repeat({grid_size}, {block_scale}px)", - }, - "tab_index": -1, - }, - [ - reactpy.html.div( - {"style": {"height": f"{block_scale}px"}, "key": i}, - [ - create_grid_block("black", block_scale, key=i) - for i in range(grid_size) - ], - ) - for i in range(grid_size) - ], - ) - - -def create_grid_block(color, block_scale, key): - return reactpy.html.div( - { - "style": { - "height": f"{block_scale}px", - "width": f"{block_scale}px", - "background_color": color, - "outline": "1px solid grey", - }, - "key": key, - } - ) - - -def assign_grid_block_color(grid, point, color): - x, y = point - block = grid["children"][x]["children"][y] - block["attributes"]["style"]["backgroundColor"] = color - - -reactpy.run(GameView) diff --git a/docs/source/reference/_examples/todo.py b/docs/source/reference/_examples/todo.py deleted file mode 100644 index 104ea59a9..000000000 --- a/docs/source/reference/_examples/todo.py +++ /dev/null @@ -1,35 +0,0 @@ -import reactpy - - -@reactpy.component -def Todo(): - items, set_items = reactpy.hooks.use_state([]) - - async def add_new_task(event): - if event["key"] == "Enter": - set_items([*items, event["target"]["value"]]) - - tasks = [] - - for index, text in enumerate(items): - - async def remove_task(event, index=index): - set_items(items[:index] + items[index + 1 :]) - - task_text = reactpy.html.td(reactpy.html.p(text)) - delete_button = reactpy.html.td( - {"on_click": remove_task}, reactpy.html.button(["x"]) - ) - tasks.append(reactpy.html.tr(task_text, delete_button)) - - task_input = reactpy.html.input({"on_key_down": add_new_task}) - task_table = reactpy.html.table(tasks) - - return reactpy.html.div( - reactpy.html.p("press enter to add a task:"), - task_input, - task_table, - ) - - -reactpy.run(Todo) diff --git a/docs/source/reference/_examples/use_reducer_counter.py b/docs/source/reference/_examples/use_reducer_counter.py deleted file mode 100644 index 6f9581dfd..000000000 --- a/docs/source/reference/_examples/use_reducer_counter.py +++ /dev/null @@ -1,27 +0,0 @@ -import reactpy - - -def reducer(count, action): - if action == "increment": - return count + 1 - elif action == "decrement": - return count - 1 - elif action == "reset": - return 0 - else: - msg = f"Unknown action '{action}'" - raise ValueError(msg) - - -@reactpy.component -def Counter(): - count, dispatch = reactpy.hooks.use_reducer(reducer, 0) - return reactpy.html.div( - f"Count: {count}", - reactpy.html.button({"on_click": lambda event: dispatch("reset")}, "Reset"), - reactpy.html.button({"on_click": lambda event: dispatch("increment")}, "+"), - reactpy.html.button({"on_click": lambda event: dispatch("decrement")}, "-"), - ) - - -reactpy.run(Counter) diff --git a/docs/source/reference/_examples/use_state_counter.py b/docs/source/reference/_examples/use_state_counter.py deleted file mode 100644 index b2d8c84a9..000000000 --- a/docs/source/reference/_examples/use_state_counter.py +++ /dev/null @@ -1,26 +0,0 @@ -import reactpy - - -def increment(last_count): - return last_count + 1 - - -def decrement(last_count): - return last_count - 1 - - -@reactpy.component -def Counter(): - initial_count = 0 - count, set_count = reactpy.hooks.use_state(initial_count) - return reactpy.html.div( - f"Count: {count}", - reactpy.html.button( - {"on_click": lambda event: set_count(initial_count)}, "Reset" - ), - reactpy.html.button({"on_click": lambda event: set_count(increment)}, "+"), - reactpy.html.button({"on_click": lambda event: set_count(decrement)}, "-"), - ) - - -reactpy.run(Counter) diff --git a/docs/source/reference/_examples/victory_chart.py b/docs/source/reference/_examples/victory_chart.py deleted file mode 100644 index ce37c522f..000000000 --- a/docs/source/reference/_examples/victory_chart.py +++ /dev/null @@ -1,7 +0,0 @@ -import reactpy - -victory = reactpy.web.module_from_template("react", "victory-bar", fallback="⌛") -VictoryBar = reactpy.web.export(victory, "VictoryBar") - -bar_style = {"parent": {"width": "500px"}, "data": {"fill": "royalblue"}} -reactpy.run(reactpy.component(lambda: VictoryBar({"style": bar_style}))) diff --git a/docs/source/reference/_static/vdom-json-schema.json b/docs/source/reference/_static/vdom-json-schema.json deleted file mode 100644 index b1005d2ed..000000000 --- a/docs/source/reference/_static/vdom-json-schema.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "$ref": "#/definitions/element", - "$schema": "http://json-schema.org/draft-07/schema", - "definitions": { - "element": { - "dependentSchemas": { - "error": { - "properties": { - "tagName": { - "maxLength": 0 - } - } - } - }, - "properties": { - "attributes": { - "type": "object" - }, - "children": { - "$ref": "#/definitions/elementChildren" - }, - "error": { - "type": "string" - }, - "eventHandlers": { - "$ref": "#/definitions/elementEventHandlers" - }, - "importSource": { - "$ref": "#/definitions/importSource" - }, - "key": { - "type": "string" - }, - "tagName": { - "type": "string" - } - }, - "required": ["tagName"], - "type": "object" - }, - "elementChildren": { - "items": { - "$ref": "#/definitions/elementOrString" - }, - "type": "array" - }, - "elementEventHandlers": { - "patternProperties": { - ".*": { - "$ref": "#/definitions/eventHander" - } - }, - "type": "object" - }, - "elementOrString": { - "if": { - "type": "object" - }, - "then": { - "$ref": "#/definitions/element" - }, - "type": ["object", "string"] - }, - "eventHandler": { - "properties": { - "preventDefault": { - "type": "boolean" - }, - "stopPropagation": { - "type": "boolean" - }, - "target": { - "type": "string" - } - }, - "required": ["target"], - "type": "object" - }, - "importSource": { - "properties": { - "fallback": { - "if": { - "not": { - "type": "null" - } - }, - "then": { - "$ref": "#/definitions/elementOrString" - }, - "type": ["object", "string", "null"] - }, - "source": { - "type": "string" - }, - "sourceType": { - "enum": ["URL", "NAME"] - }, - "unmountBeforeUpdate": { - "type": "boolean" - } - }, - "required": ["source"], - "type": "object" - } - } -} diff --git a/docs/source/reference/browser-events.rst b/docs/source/reference/browser-events.rst deleted file mode 100644 index 632be410a..000000000 --- a/docs/source/reference/browser-events.rst +++ /dev/null @@ -1,65 +0,0 @@ -.. _Browser Events: - -Browser Events 🚧 -================= - -The event types below are triggered by an event in the bubbling phase. To register an -event handler for the capture phase, append Capture to the event name; for example, -instead of using ``onClick``, you would use ``onClickCapture`` to handle the click event -in the capture phase. - -.. note:: - - Under construction 🚧 - - -Clipboard Events ----------------- - -Composition Events ------------------- - -Keyboard Events ---------------- - -Focus Events ------------- - -Form Events ------------ - -Generic Events --------------- - -Mouse Events ------------- - -Pointer Events --------------- - -Selection Events ----------------- - -Touch Events ------------- - -UI Events ---------- - -Wheel Events ------------- - -Media Events ------------- - -Image Events ------------- - -Animation Events ----------------- - -Transition Events ------------------ - -Other Events ------------- diff --git a/docs/source/reference/hooks-api.rst b/docs/source/reference/hooks-api.rst deleted file mode 100644 index ca8123e85..000000000 --- a/docs/source/reference/hooks-api.rst +++ /dev/null @@ -1,379 +0,0 @@ -========= -Hooks API -========= - -Hooks are functions that allow you to "hook into" the life cycle events and state of -Components. Their usage should always follow the :ref:`Rules of Hooks`. For most use -cases the :ref:`Basic Hooks` should be enough, however the remaining -:ref:`Supplementary Hooks` should fulfill less common scenarios. - - -Basic Hooks -=========== - - -Use State ---------- - -.. code-block:: - - state, set_state = use_state(initial_state) - -Returns a stateful value and a function to update it. - -During the first render the ``state`` will be identical to the ``initial_state`` passed -as the first argument. However in subsequent renders ``state`` will take on the value -passed to ``set_state``. - -.. code-block:: - - set_state(new_state) - -The ``set_state`` function accepts a ``new_state`` as its only argument and schedules a -re-render of the component where ``use_state`` was initially called. During these -subsequent re-renders the ``state`` returned by ``use_state`` will take on the value -of ``new_state``. - -.. note:: - - The identity of ``set_state`` is guaranteed to be preserved across renders. This - means it can safely be omitted from dependency lists in :ref:`Use Effect` or - :ref:`Use Callback`. - - -Functional Updates -.................. - -If the new state is computed from the previous state, you can pass a function which -accepts a single argument (the previous state) and returns the next state. Consider this -simply use case of a counter where we've pulled out logic for increment and -decremented the count: - -.. reactpy:: _examples/use_state_counter - -We use the functional form for the "+" and "-" buttons since the next ``count`` depends -on the previous value, while for the "Reset" button we simple assign the -``initial_count`` since it is independent of the prior ``count``. This is a trivial -example, but it demonstrates how complex state logic can be factored out into well -defined and potentially reusable functions. - - -Lazy Initial State -.................. - -In cases where it is costly to create the initial value for ``use_state``, you can pass -a constructor function that accepts no arguments instead - it will be called on the -first render of a component, but will be disregarded in all following renders: - -.. code-block:: - - state, set_state = use_state(lambda: some_expensive_computation(a, b, c)) - - -Skipping Updates -................ - -If you update a State Hook to the same value as the current state then the component which -owns that state will not be rendered again. We check ``if new_state is current_state`` -in order to determine whether there has been a change. Thus the following would not -result in a re-render: - -.. code-block:: - - state, set_state = use_state([]) - set_state(state) - - -Use Effect ----------- - -.. code-block:: - - use_effect(did_render) - -The ``use_effect`` hook accepts a function which may be imperative, or mutate state. The -function will be called immediately after the layout has fully updated. - -Asynchronous actions, mutations, subscriptions, and other `side effects`_ can cause -unexpected bugs if placed in the main body of a component's render function. Thus the -``use_effect`` hook provides a way to safely escape the purely functional world of -component render functions. - -.. note:: - - Normally in React the ``did_render`` function is called once an update has been - committed to the screen. Since no such action is performed by ReactPy, and the time - at which the update is displayed cannot be known we are unable to achieve parity - with this behavior. - - -Cleaning Up Effects -................... - -If the effect you wish to enact creates resources, you'll probably need to clean them -up. In such cases you may simply return a function that addresses this from the -``did_render`` function which created the resource. Consider the case of opening and -then closing a connection: - -.. code-block:: - - def establish_connection(): - connection = open_connection() - return lambda: close_connection(connection) - - use_effect(establish_connection) - -The clean-up function will be run before the component is unmounted or, before the next -effect is triggered when the component re-renders. You can -:ref:`conditionally fire events <Conditional Effects>` to avoid triggering them each -time a component renders. - - -Conditional Effects -................... - -By default, effects are triggered after every successful render to ensure that all state -referenced by the effect is up to date. However, when an effect function references -non-global variables, the effect will only if the value of that variable changes. For -example, imagine that we had an effect that connected to a ``url`` state variable: - -.. code-block:: - - url, set_url = use_state("https://example.com") - - def establish_connection(): - connection = open_connection(url) - return lambda: close_connection(connection) - - use_effect(establish_connection) - -Here, a new connection will be established whenever a new ``url`` is set. - - -Async Effects -............. - -A behavior unique to ReactPy's implementation of ``use_effect`` is that it natively -supports ``async`` functions: - -.. code-block:: - - async def non_blocking_effect(): - resource = await do_something_asynchronously() - return lambda: blocking_close(resource) - - use_effect(non_blocking_effect) - - -There are **three important subtleties** to note about using asynchronous effects: - -1. The cleanup function must be a normal synchronous function. - -2. Asynchronous effects which do not complete before the next effect is created - following a re-render will be cancelled. This means an - :class:`~asyncio.CancelledError` will be raised somewhere in the body of the effect. - -3. An asynchronous effect may occur any time after the update which added this effect - and before the next effect following a subsequent update. - - -Manual Effect Conditions -........................ - -In some cases, you may want to explicitly declare when an effect should be triggered. -You can do this by passing ``dependencies`` to ``use_effect``. Each of the following -values produce different effect behaviors: - -- ``use_effect(..., dependencies=None)`` - triggers and cleans up on every render. -- ``use_effect(..., dependencies=[])`` - only triggers on the first and cleans up after - the last render. -- ``use_effect(..., dependencies=[x, y])`` - triggers on the first render and on subsequent renders if - ``x`` or ``y`` have changed. - - -Use Context ------------ - -.. code-block:: - - value = use_context(MyContext) - -Accepts a context object (the value returned from -:func:`reactpy.core.hooks.create_context`) and returns the current context value for that -context. The current context value is determined by the ``value`` argument passed to the -nearest ``MyContext`` in the tree. - -When the nearest <MyContext.Provider> above the component updates, this Hook will -trigger a rerender with the latest context value passed to that MyContext provider. Even -if an ancestor uses React.memo or shouldComponentUpdate, a rerender will still happen -starting at the component itself using useContext. - - -Supplementary Hooks -=================== - - -Use Reducer ------------ - -.. code-block:: - - state, dispatch_action = use_reducer(reducer, initial_state) - -An alternative and derivative of :ref:`Use State` the ``use_reducer`` hook, instead of -directly assigning a new state, allows you to specify an action which will transition -the previous state into the next state. This transition is defined by a reducer function -of the form ``(current_state, action) -> new_state``. The ``use_reducer`` hook then -returns the current state and a ``dispatch_action`` function that accepts an ``action`` -and causes a transition to the next state via the ``reducer``. - -``use_reducer`` is generally preferred to ``use_state`` if logic for transitioning from -one state to the next is especially complex or involves nested data structures. -``use_reducer`` can also be used to collect several ``use_state`` calls together - this -may be slightly more performant as well as being preferable since there is only one -``dispatch_action`` callback versus the many ``set_state`` callbacks. - -We can rework the :ref:`Functional Updates` counter example to use ``use_reducer``: - -.. reactpy:: _examples/use_reducer_counter - -.. note:: - - The identity of the ``dispatch_action`` function is guaranteed to be preserved - across re-renders throughout the lifetime of the component. This means it can safely - be omitted from dependency lists in :ref:`Use Effect` or :ref:`Use Callback`. - - -Use Callback ------------- - -.. code-block:: - - memoized_callback = use_callback(lambda: do_something(a, b)) - -A derivative of :ref:`Use Memo`, the ``use_callback`` hook returns a -`memoized <memoization>`_ callback. This is useful when passing callbacks to child -components which check reference equality to prevent unnecessary renders. The -``memoized_callback`` will only change when any local variables is references do. - -.. note:: - - You may manually specify what values the callback depends on in the :ref:`same way - as effects <Manual Effect Conditions>` using the ``dependencies`` parameter. - - -Use Memo --------- - -.. code-block:: - - memoized_value = use_memo(lambda: compute_something_expensive(a, b)) - -Returns a `memoized <memoization>`_ value. By passing a constructor function accepting -no arguments and an array of dependencies for that constructor, the ``use_callback`` -hook will return the value computed by the constructor. The ``memoized_value`` will only -be recomputed if a local variable referenced by the constructor changes (e.g. ``a`` or -``b`` here). This optimizes performance because you don't need to -``compute_something_expensive`` on every render. - -Unlike ``use_effect`` the constructor function is called during each render (instead of -after) and should not incur side effects. - -.. warning:: - - Remember that you shouldn't optimize something unless you know it's a performance - bottleneck. Write your code without ``use_memo`` first and then add it to targeted - sections that need a speed-up. - -.. note:: - - You may manually specify what values the callback depends on in the :ref:`same way - as effects <Manual Effect Conditions>` using the ``dependencies`` parameter. - - -Use Ref -------- - -.. code-block:: - - ref_container = use_ref(initial_value) - -Returns a mutable :class:`~reactpy.utils.Ref` object that has a single -:attr:`~reactpy.utils.Ref.current` attribute that at first contains the ``initial_state``. -The identity of the ``Ref`` object will be preserved for the lifetime of the component. - -A ``Ref`` is most useful if you need to incur side effects since updating its -``.current`` attribute doesn't trigger a re-render of the component. You'll often use this -hook alongside :ref:`Use Effect` or in response to component event handlers. - - -.. links -.. ===== - -.. _React Hooks: https://reactjs.org/docs/hooks-reference.html -.. _side effects: https://en.wikipedia.org/wiki/Side_effect_(computer_science) -.. _memoization: https://en.wikipedia.org/wiki/Memoization - - -Rules of Hooks -============== - -Hooks are just normal Python functions, but there's a bit of magic to them, and in order -for that magic to work you've got to follow two rules. Thankfully we supply a -:ref:`Flake8 Plugin` to help enforce them. - - -Only call outside flow controls -------------------------------- - -**Don't call hooks inside loops, conditions, or nested functions.** Instead you must -always call hooks at the top level of your functions. By adhering to this rule you -ensure that hooks are always called in the exact same order. This fact is what allows -ReactPy to preserve the state of hooks between multiple calls to ``useState`` and -``useEffect`` calls. - - -Only call in render functions ------------------------------ - -**Don't call hooks from regular Python functions.** Instead you should: - -- ✅ Call Hooks from a component's render function. - -- ✅ Call Hooks from another custom hook - -Following this rule ensures stateful logic for ReactPy component is always clearly -separated from the rest of your codebase. - - -Flake8 Plugin -------------- - -We provide a Flake8 plugin called `flake8-reactpy-hooks <Flake8 Linter Plugin>`_ that helps -to enforce the two rules described above. You can ``pip`` install it directly, or with -the ``lint`` extra for ReactPy: - -.. code-block:: bash - - pip install flake8-reactpy-hooks - -Once installed running, ``flake8`` on your code will start catching errors. For example: - -.. code-block:: bash - - flake8 my_reactpy_components.py - -Might produce something like the following output: - -.. code-block:: text - - ./my_reactpy_components:10:8 ROH102 hook 'use_effect' used inside if statement - ./my_reactpy_components:23:4 ROH102 hook 'use_state' used outside component or hook definition - -See the Flake8 docs for -`more info <https://flake8.pycqa.org/en/latest/user/configuration.html>`__. - -.. links -.. ===== - -.. _Flake8 Linter Plugin: https://github.com/reactive-python/flake8-reactpy-hooks diff --git a/docs/source/reference/html-attributes.rst b/docs/source/reference/html-attributes.rst deleted file mode 100644 index 91813c355..000000000 --- a/docs/source/reference/html-attributes.rst +++ /dev/null @@ -1,197 +0,0 @@ -.. testcode:: - - from reactpy import html - - -HTML Attributes -=============== - -In ReactPy, HTML attributes are specified using snake_case instead of dash-separated -words. For example, ``tabindex`` and ``margin-left`` become ``tab_index`` and -``margin_left`` respectively. - - -Notable Attributes -------------------- - -Some attributes in ReactPy are renamed, have special meaning, or are used differently -than in HTML. - -``style`` -......... - -As mentioned above, instead of using a string to specify the ``style`` attribute, we use -a dictionary to describe the CSS properties we want to apply to an element. For example, -the following HTML: - -.. code-block:: html - - <div style="width: 50%; margin-left: 25%;"> - <h1 style="margin-top: 0px;">My Todo List</h1> - <ul> - <li>Build a cool new app</li> - <li>Share it with the world!</li> - </ul> - </div> - -Would be written in ReactPy as: - -.. testcode:: - - html.div( - { - "style": { - "width": "50%", - "margin_left": "25%", - }, - }, - html.h1( - { - "style": { - "margin_top": "0px", - }, - }, - "My Todo List", - ), - html.ul( - html.li("Build a cool new app"), - html.li("Share it with the world!"), - ), - ) - -``class`` vs ``class_name`` -........................... - -In HTML, the ``class`` attribute is used to specify a CSS class for an element. In -ReactPy, this attribute is renamed to ``class_name`` to avoid conflicting with the -``class`` keyword in Python. For example, the following HTML: - -.. code-block:: html - - <div class="container"> - <h1 class="title">My Todo List</h1> - <ul class="list"> - <li class="item">Build a cool new app</li> - <li class="item">Share it with the world!</li> - </ul> - </div> - -Would be written in ReactPy as: - -.. testcode:: - - html.div( - {"class_name": "container"}, - html.h1({"class_name": "title"}, "My Todo List"), - html.ul( - {"class_name": "list"}, - html.li({"class_name": "item"}, "Build a cool new app"), - html.li({"class_name": "item"}, "Share it with the world!"), - ), - ) - -``for`` vs ``html_for`` -....................... - -In HTML, the ``for`` attribute is used to specify the ``id`` of the element it's -associated with. In ReactPy, this attribute is renamed to ``html_for`` to avoid -conflicting with the ``for`` keyword in Python. For example, the following HTML: - -.. code-block:: html - - <div> - <label for="todo">Todo:</label> - <input id="todo" type="text" /> - </div> - -Would be written in ReactPy as: - -.. testcode:: - - html.div( - html.label({"html_for": "todo"}, "Todo:"), - html.input({"id": "todo", "type": "text"}), - ) - -``dangerously_set_inner_HTML`` -.............................. - -This is used to set the ``innerHTML`` property of an element and should be provided a -dictionary with a single key ``__html`` whose value is the HTML to be set. It should be -used with **extreme caution** as it can lead to XSS attacks if the HTML inside isn't -trusted (for example if it comes from user input). - - -All Attributes --------------- - -`access_key <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey>`__ - A string. Specifies a keyboard shortcut for the element. Not generally recommended. - -`aria_* <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes>`__ - ARIA attributes let you specify the accessibility tree information for this element. - See ARIA attributes for a complete reference. In ReactPy, all ARIA attribute names are - exactly the same as in HTML. - -`auto_capitalize <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autocapitalize>`__ - A string. Specifies whether and how the user input should be capitalized. - -`content_editable <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable>`__ - A boolean. If true, the browser lets the user edit the rendered element directly. This - is used to implement rich text input libraries like Lexical. ReactPy warns if you try - to pass children to an element with ``content_editable = True`` because ReactPy will - not be able to update its content after user edits. - -`data_* <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*>`__ - Data attributes let you attach some string data to the element, for example - data-fruit="banana". In ReactPy, they are not commonly used because you would usually - read data from props or state instead. - -`dir <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir>`__ - Either ``"ltr"`` or ``"rtl"``. Specifies the text direction of the element. - -`draggable <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable>`__ - A boolean. Specifies whether the element is draggable. Part of HTML Drag and Drop API. - -`enter_key_hint <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint>`__ - A string. Specifies which action to present for the enter key on virtual keyboards. - -`hidden <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden>`__ - A boolean or a string. Specifies whether the element should be hidden. - -- `id <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id>`__: - A string. Specifies a unique identifier for this element, which can be used to find it - later or connect it with other elements. Generate it with useId to avoid clashes - between multiple instances of the same component. - -`is <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-is>`__ - A string. If specified, the component will behave like a custom element. - -`input_mode <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode>`__ - A string. Specifies what kind of keyboard to display (for example, text, number, or telephone). - -`item_prop <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/itemprop>`__ - A string. Specifies which property the element represents for structured data crawlers. - -`lang <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang>`__ - A string. Specifies the language of the element. - -`role <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/role>`__ - A string. Specifies the element role explicitly for assistive technologies. - -`slot <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot>`__ - A string. Specifies the slot name when using shadow DOM. In ReactPy, an equivalent - pattern is typically achieved by passing JSX as props, for example - ``<Layout left={<Sidebar />} right={<Content />} />``. - -`spell_check <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck>`__ - A boolean or null. If explicitly set to true or false, enables or disables spellchecking. - -`tab_index <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex>`__ - A number. Overrides the default Tab button behavior. Avoid using values other than -1 and 0. - -`title <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title>`__ - A string. Specifies the tooltip text for the element. - -`translate <https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/translate>`__ - Either 'yes' or 'no'. Passing 'no' excludes the element content from being translated. diff --git a/docs/source/reference/javascript-api.rst b/docs/source/reference/javascript-api.rst deleted file mode 100644 index 2587be82d..000000000 --- a/docs/source/reference/javascript-api.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _Javascript API: - -Javascript API 🚧 -================= - -.. note:: - - Under construction 🚧 diff --git a/docs/source/reference/specifications.rst b/docs/source/reference/specifications.rst deleted file mode 100644 index 3a5c94893..000000000 --- a/docs/source/reference/specifications.rst +++ /dev/null @@ -1,170 +0,0 @@ -Specifications -============== - -Describes various data structures and protocols used to define and communicate virtual -document object models (:ref:`VDOM`). The definitions below follow in the footsteps of -`a specification <https://github.com/nteract/vdom/blob/master/docs/mimetype-spec.md>`_ -created by `Nteract <https://nteract.io>`_ and which was built into -`JupyterLab <https://jupyterlab.readthedocs.io/en/stable/>`_. While ReactPy's specification -for VDOM is fairly well established, it should not be relied until it's been fully -adopted by the aforementioned organizations. - - -VDOM ----- - -A set of definitions that explain how ReactPy creates a virtual representation of -the document object model. We'll begin by looking at a bit of HTML that we'll convert -into its VDOM representation: - -.. code-block:: html - - <div> - Put your name here: - <input - type="text" - minlength="4" - maxlength="8" - onchange="a_python_callback(event)" - /> - </div> - -.. note:: - - For context, the following Python code would generate the HTML above: - - .. code-block:: python - - import reactpy - - async def a_python_callback(new): - ... - - name_input_view = reactpy.html.div( - reactpy.html.input( - { - "type": "text", - "minLength": 4, - "maxLength": 8, - "onChange": a_python_callback, - } - ), - ["Put your name here: "], - ) - -We'll take this step by step in order to show exactly where each piece of the VDOM -model comes from. To get started we'll convert the outer ``<div/>``: - -.. code-block:: python - - { - "tagName": "div", - "children": [ - "To perform an action", - ... - ], - "attributes": {}, - "eventHandlers": {} - } - -.. note:: - - As we move though our conversation we'll be using ``...`` to fill in places that we - haven't converted yet. - -In this simple case, all we've done is take the name of the HTML element (``div`` in -this case) and inserted it into the ``tagName`` field of a dictionary. Then we've taken -the inner HTML and added to a list of children where the text ``"to perform an action"`` -has been made into a string, and the inner ``input`` (yet to be converted) will be -expanded out into its own VDOM representation. Since the outer ``div`` is pretty simple -there aren't any ``attributes`` or ``eventHandlers``. - -No we come to the inner ``input``. If we expand this out now we'll get the following: - -.. code-block:: python - - { - "tagName": "div", - "children": [ - "To perform an action", - { - "tagName": "input", - "children": [], - "attributes": { - "type": "text", - "minLength": 4, - "maxLength": 8 - }, - "eventHandlers": ... - } - ], - "attributes": {}, - "eventHandlers": {} - } - -Here we've had to add some attributes to our VDOM. Take note of the differing -capitalization - instead of using all lowercase (an HTML convention) we've used -`camelCase <https://en.wikipedia.org/wiki/Camel_case>`_ which is very common -in JavaScript. - -Last, but not least we come to the ``eventHandlers`` for the ``input``: - -.. code-block:: python - - { - "tagName": "div", - "children": [ - "To perform an action", - { - "tagName": "input", - "children": [], - "attributes": { - "type": "text", - "minLength": 4, - "maxLength": 8 - }, - "eventHandlers": { - "onChange": { - "target": "unique-id-of-a_python_callback", - "preventDefault": False, - "stopPropagation": False - } - } - } - ], - "attributes": {}, - "eventHandlers": {} - } - -Again we've changed the all lowercase ``onchange`` into a cameCase ``onChange`` event -type name. The various properties for the ``onChange`` handler are: - -- ``target``: the unique ID for a Python callback that exists in the backend. - -- ``preventDefault``: Stop the event's default action. More info - `here <https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault>`__. - -- ``stopPropagation``: prevent the event from bubbling up through the DOM. More info - `here <https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation>`__. - - -VDOM JSON Schema -................ - -To clearly describe the VDOM spec we've created a `JSON Schema <https://json-schema.org/>`_: - -.. literalinclude:: _static/vdom-json-schema.json - :language: json - - -JSON Patch ----------- - -Updates to VDOM modules are sent using the `JSON Patch`_ specification. - -... this section is still Under construction 🚧 - - -.. Links -.. ===== -.. _JSON Patch: http://jsonpatch.com/ diff --git a/docs/src/about/changelog.md b/docs/src/about/changelog.md new file mode 100644 index 000000000..e62f7dbe1 --- /dev/null +++ b/docs/src/about/changelog.md @@ -0,0 +1,12 @@ +--- +hide: + - toc +--- + +<p class="intro" markdown> + +{% include-markdown "../../../CHANGELOG.md" start="<!--attr-start-->" end="<!--attr-end-->" %} + +</p> + +{% include-markdown "../../../CHANGELOG.md" start="<!--changelog-start-->" %} diff --git a/docs/docs_app/__init__.py b/docs/src/about/code.md similarity index 100% rename from docs/docs_app/__init__.py rename to docs/src/about/code.md diff --git a/src/py/reactpy/reactpy/_console/__init__.py b/docs/src/about/community.md similarity index 100% rename from src/py/reactpy/reactpy/_console/__init__.py rename to docs/src/about/community.md diff --git a/src/py/reactpy/reactpy/backend/__init__.py b/docs/src/about/docs.md similarity index 100% rename from src/py/reactpy/reactpy/backend/__init__.py rename to docs/src/about/docs.md diff --git a/src/py/reactpy/reactpy/core/__init__.py b/docs/src/about/licenses.md similarity index 100% rename from src/py/reactpy/reactpy/core/__init__.py rename to docs/src/about/licenses.md diff --git a/src/py/reactpy/reactpy/future.py b/docs/src/about/running-tests.md similarity index 100% rename from src/py/reactpy/reactpy/future.py rename to docs/src/about/running-tests.md diff --git a/docs/src/assets/css/admonition.css b/docs/src/assets/css/admonition.css new file mode 100644 index 000000000..8b3f06ef6 --- /dev/null +++ b/docs/src/assets/css/admonition.css @@ -0,0 +1,160 @@ +[data-md-color-scheme="slate"] { + --admonition-border-color: transparent; + --admonition-expanded-border-color: rgba(255, 255, 255, 0.1); + --note-bg-color: rgba(43, 110, 98, 0.2); + --terminal-bg-color: #0c0c0c; + --terminal-title-bg-color: #000; + --deep-dive-bg-color: rgba(43, 52, 145, 0.2); + --you-will-learn-bg-color: #353a45; + --pitfall-bg-color: rgba(182, 87, 0, 0.2); +} +[data-md-color-scheme="default"] { + --admonition-border-color: rgba(0, 0, 0, 0.08); + --admonition-expanded-border-color: var(--admonition-border-color); + --note-bg-color: rgb(244, 251, 249); + --terminal-bg-color: rgb(64, 71, 86); + --terminal-title-bg-color: rgb(35, 39, 47); + --deep-dive-bg-color: rgb(243, 244, 253); + --you-will-learn-bg-color: rgb(246, 247, 249); + --pitfall-bg-color: rgb(254, 245, 231); +} + +.md-typeset details, +.md-typeset .admonition { + border-color: var(--admonition-border-color) !important; + box-shadow: none; +} + +.md-typeset :is(.admonition, details) { + margin: 0.55em 0; +} + +.md-typeset .admonition { + font-size: 0.7rem; +} + +.md-typeset .admonition:focus-within, +.md-typeset details:focus-within { + box-shadow: none !important; +} + +.md-typeset details[open] { + border-color: var(--admonition-expanded-border-color) !important; +} + +/* +Admonition: "summary" +React Name: "You will learn" +*/ +.md-typeset .admonition.summary { + background: var(--you-will-learn-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .summary .admonition-title { + font-size: 1rem; + background: transparent; + padding-left: 0.6rem; + padding-bottom: 0; +} + +.md-typeset .summary .admonition-title:before { + display: none; +} + +.md-typeset .admonition.summary { + border-color: #ffffff17 !important; +} + +/* +Admonition: "abstract" +React Name: "Note" +*/ +.md-typeset .admonition.abstract { + background: var(--note-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .abstract .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(68, 172, 153); +} + +.md-typeset .abstract .admonition-title:before { + font-size: 1.1rem; + background: rgb(68, 172, 153); +} + +/* +Admonition: "warning" +React Name: "Pitfall" +*/ +.md-typeset .admonition.warning { + background: var(--pitfall-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .warning .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(219, 125, 39); +} + +.md-typeset .warning .admonition-title:before { + font-size: 1.1rem; + background: rgb(219, 125, 39); +} + +/* +Admonition: "info" +React Name: "Deep Dive" +*/ +.md-typeset .admonition.info { + background: var(--deep-dive-bg-color); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +.md-typeset .info .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(136, 145, 236); +} + +.md-typeset .info .admonition-title:before { + font-size: 1.1rem; + background: rgb(136, 145, 236); +} + +/* +Admonition: "example" +React Name: "Terminal" +*/ +.md-typeset .admonition.example { + background: var(--terminal-bg-color); + border-radius: 0.4rem; + overflow: hidden; + border: none; +} + +.md-typeset .example .admonition-title { + background: var(--terminal-title-bg-color); + color: rgb(246, 247, 249); +} + +.md-typeset .example .admonition-title:before { + background: rgb(246, 247, 249); +} + +.md-typeset .admonition.example code { + background: transparent; + color: #fff; + box-shadow: none; +} diff --git a/docs/src/assets/css/banner.css b/docs/src/assets/css/banner.css new file mode 100644 index 000000000..3739a73c1 --- /dev/null +++ b/docs/src/assets/css/banner.css @@ -0,0 +1,15 @@ +body[data-md-color-scheme="slate"] { + --md-banner-bg-color: rgb(55, 81, 78); + --md-banner-font-color: #fff; +} + +body[data-md-color-scheme="default"] { + --md-banner-bg-color: #ff9; + --md-banner-font-color: #000; +} + +.md-banner--warning { + background-color: var(--md-banner-bg-color); + color: var(--md-banner-font-color); + text-align: center; +} diff --git a/docs/src/assets/css/button.css b/docs/src/assets/css/button.css new file mode 100644 index 000000000..8f71391aa --- /dev/null +++ b/docs/src/assets/css/button.css @@ -0,0 +1,41 @@ +[data-md-color-scheme="slate"] { + --md-button-font-color: #fff; + --md-button-border-color: #404756; +} + +[data-md-color-scheme="default"] { + --md-button-font-color: #000; + --md-button-border-color: #8d8d8d; +} + +.md-typeset .md-button { + border-width: 1px; + border-color: var(--md-button-border-color); + border-radius: 9999px; + color: var(--md-button-font-color); + transition: color 125ms, background 125ms, border-color 125ms, + transform 125ms; +} + +.md-typeset .md-button:focus, +.md-typeset .md-button:hover { + border-color: var(--md-button-border-color); + color: var(--md-button-font-color); + background: rgba(78, 87, 105, 0.05); +} + +.md-typeset .md-button.md-button--primary { + color: #fff; + border-color: transparent; + background: var(--reactpy-color-dark); +} + +.md-typeset .md-button.md-button--primary:focus, +.md-typeset .md-button.md-button--primary:hover { + border-color: transparent; + background: var(--reactpy-color-darker); +} + +.md-typeset .md-button:focus { + transform: scale(0.98); +} diff --git a/docs/src/assets/css/code.css b/docs/src/assets/css/code.css new file mode 100644 index 000000000..c54654980 --- /dev/null +++ b/docs/src/assets/css/code.css @@ -0,0 +1,111 @@ +:root { + --code-max-height: 17.25rem; + --md-code-backdrop: rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.03) 0px 0.8px 2px 0px, + rgba(0, 0, 0, 0.047) 0px 2.7px 6.7px 0px, + rgba(0, 0, 0, 0.08) 0px 12px 30px 0px; +} +[data-md-color-scheme="slate"] { + --md-code-hl-color: #ffffcf1c; + --md-code-bg-color: #16181d; + --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); + --code-tab-color: rgb(52, 58, 70); + --md-code-hl-name-color: #aadafc; + --md-code-hl-string-color: hsl(21 49% 63% / 1); + --md-code-hl-keyword-color: hsl(289.67deg 35% 60%); + --md-code-hl-constant-color: hsl(213.91deg 68% 61%); + --md-code-hl-number-color: #bfd9ab; + --func-and-decorator-color: #dcdcae; + --module-import-color: #60c4ac; +} +[data-md-color-scheme="default"] { + --md-code-hl-color: #ffffcf1c; + --md-code-bg-color: rgba(208, 211, 220, 0.4); + --md-code-fg-color: rgb(64, 71, 86); + --code-tab-color: #fff; + --func-and-decorator-color: var(--md-code-hl-function-color); + --module-import-color: #e153e5; +} +[data-md-color-scheme="default"] .md-typeset .highlight > pre > code, +[data-md-color-scheme="default"] .md-typeset .highlight > table.highlighttable { + --md-code-bg-color: #fff; +} + +/* All code blocks */ +.md-typeset pre > code { + max-height: var(--code-max-height); +} + +/* Code blocks with no line number */ +.md-typeset .highlight > pre > code { + border-radius: 16px; + max-height: var(--code-max-height); + box-shadow: var(--md-code-backdrop); +} + +/* Code blocks with line numbers */ +.md-typeset .highlighttable .linenos { + max-height: var(--code-max-height); + overflow: hidden; +} +.md-typeset .highlighttable { + box-shadow: var(--md-code-backdrop); + border-radius: 8px; + overflow: hidden; +} + +/* Tabbed code blocks */ +.md-typeset .tabbed-set { + box-shadow: var(--md-code-backdrop); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--md-default-fg-color--lightest); +} +.md-typeset .tabbed-set .tabbed-block { + overflow: hidden; +} +.js .md-typeset .tabbed-set .tabbed-labels { + background: var(--code-tab-color); + margin: 0; + padding-left: 0.8rem; +} +.md-typeset .tabbed-set .tabbed-labels > label { + font-weight: 400; + font-size: 0.7rem; + padding-top: 0.55em; + padding-bottom: 0.35em; +} +.md-typeset .tabbed-set .highlighttable { + border-radius: 0; +} + +/* Code hightlighting colors */ + +/* Module imports */ +.highlight .nc, +.highlight .ne, +.highlight .nn, +.highlight .nv { + color: var(--module-import-color); +} + +/* Function def name and decorator */ +.highlight .nd, +.highlight .nf { + color: var(--func-and-decorator-color); +} + +/* None type */ +.highlight .kc { + color: var(--md-code-hl-constant-color); +} + +/* Keywords such as def and return */ +.highlight .k { + color: var(--md-code-hl-constant-color); +} + +/* HTML tags */ +.highlight .nt { + color: var(--md-code-hl-constant-color); +} diff --git a/docs/src/assets/css/footer.css b/docs/src/assets/css/footer.css new file mode 100644 index 000000000..b3408286e --- /dev/null +++ b/docs/src/assets/css/footer.css @@ -0,0 +1,33 @@ +[data-md-color-scheme="slate"] { + --md-footer-bg-color: var(--md-default-bg-color); + --md-footer-bg-color--dark: var(--md-default-bg-color); + --md-footer-border-color: var(--md-header-border-color); +} + +[data-md-color-scheme="default"] { + --md-footer-fg-color: var(--md-typeset-color); + --md-footer-fg-color--light: var(--md-typeset-color); + --md-footer-bg-color: var(--md-default-bg-color); + --md-footer-bg-color--dark: var(--md-default-bg-color); + --md-footer-border-color: var(--md-header-border-color); +} + +.md-footer { + border-top: 1px solid var(--md-footer-border-color); +} + +.md-copyright { + width: 100%; +} + +.md-copyright__highlight { + width: 100%; +} + +.legal-footer-right { + float: right; +} + +.md-copyright__highlight div { + display: inline; +} diff --git a/docs/src/assets/css/home.css b/docs/src/assets/css/home.css new file mode 100644 index 000000000..c72e7093a --- /dev/null +++ b/docs/src/assets/css/home.css @@ -0,0 +1,335 @@ +img.home-logo { + height: 120px; +} + +.home .row { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 6rem 0.8rem; +} + +.home .row:not(.first, .stripe) { + background: var(--row-bg-color); +} + +.home .row.stripe { + background: var(--row-stripe-bg-color); + border: 0 solid var(--stripe-border-color); + border-top-width: 1px; + border-bottom-width: 1px; +} + +.home .row.first { + text-align: center; +} + +.home .row h1 { + max-width: 28rem; + line-height: 1.15; + font-weight: 500; + margin-bottom: 0.55rem; + margin-top: -1rem; +} + +.home .row.first h1 { + margin-top: 0.55rem; + margin-bottom: -0.75rem; +} + +.home .row > p { + max-width: 35rem; + line-height: 1.5; + font-weight: 400; +} + +.home .row.first > p { + font-size: 32px; + font-weight: 500; +} + +/* Code blocks */ +.home .row .tabbed-set { + background: var(--home-tabbed-set-bg-color); + margin: 0; +} + +.home .row .tabbed-content { + padding: 20px 18px; + overflow-x: auto; +} + +.home .row .tabbed-content img { + user-select: none; + -moz-user-select: none; + -webkit-user-drag: none; + -webkit-user-select: none; + -ms-user-select: none; + max-width: 580px; +} + +.home .row .tabbed-content { + -webkit-filter: var(--code-block-filter); + filter: var(--code-block-filter); +} + +/* Code examples */ +.home .example-container { + background: radial-gradient( + circle at 0% 100%, + rgb(41 84 147 / 11%) 0%, + rgb(22 89 189 / 4%) 70%, + rgb(48 99 175 / 0%) 80% + ), + radial-gradient( + circle at 100% 100%, + rgb(24 87 45 / 55%) 0%, + rgb(29 61 12 / 4%) 70%, + rgb(94 116 93 / 0%) 80% + ), + radial-gradient( + circle at 100% 0%, + rgba(54, 66, 84, 0.55) 0%, + rgb(102 111 125 / 4%) 70%, + rgba(54, 66, 84, 0) 80% + ), + radial-gradient( + circle at 0% 0%, + rgba(91, 114, 135, 0.55) 0%, + rgb(45 111 171 / 4%) 70%, + rgb(5 82 153 / 0%) 80% + ), + rgb(0, 0, 0) center center/cover no-repeat fixed; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + border-radius: 16px; + margin: 30px 0; + max-width: 100%; + grid-column-gap: 20px; + padding-left: 20px; + padding-right: 20px; +} + +.home .demo .white-bg { + background: #fff; + border-radius: 16px; + display: flex; + flex-direction: column; + max-width: 590px; + min-width: -webkit-min-content; + min-width: -moz-min-content; + min-width: min-content; + row-gap: 1rem; + padding: 1rem; +} + +.home .demo .vid-row { + display: flex; + flex-direction: row; + -moz-column-gap: 12px; + column-gap: 12px; +} + +.home .demo { + color: #000; +} + +.home .demo .vid-thumbnail { + background: radial-gradient( + circle at 0% 100%, + rgb(41 84 147 / 55%) 0%, + rgb(22 89 189 / 4%) 70%, + rgb(48 99 175 / 0%) 80% + ), + radial-gradient( + circle at 100% 100%, + rgb(24 63 87 / 55%) 0%, + rgb(29 61 12 / 4%) 70%, + rgb(94 116 93 / 0%) 80% + ), + radial-gradient( + circle at 100% 0%, + rgba(54, 66, 84, 0.55) 0%, + rgb(102 111 125 / 4%) 70%, + rgba(54, 66, 84, 0) 80% + ), + radial-gradient( + circle at 0% 0%, + rgba(91, 114, 135, 0.55) 0%, + rgb(45 111 171 / 4%) 70%, + rgb(5 82 153 / 0%) 80% + ), + rgb(0, 0, 0) center center/cover no-repeat fixed; + width: 9rem; + aspect-ratio: 16 / 9; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; +} + +.home .demo .vid-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + width: 100%; +} + +.home .demo h2 { + font-size: 18px; + line-height: 1.375; + margin: 0; + text-align: left; + font-weight: 700; +} + +.home .demo h3 { + font-size: 16px; + line-height: 1.25; + margin: 0; +} + +.home .demo p { + font-size: 14px; + line-height: 1.375; + margin: 0; +} + +.home .demo .browser-nav-url { + background: rgba(153, 161, 179, 0.2); + border-radius: 9999px; + font-size: 14px; + color: grey; + display: flex; + align-items: center; + justify-content: center; + -moz-column-gap: 5px; + column-gap: 5px; +} + +.home .demo .browser-navbar { + margin: -1rem; + margin-bottom: 0; + padding: 0.75rem 1rem; + border-bottom: 1px solid darkgrey; +} + +.home .demo .browser-viewport { + background: #fff; + border-radius: 16px; + display: flex; + flex-direction: column; + row-gap: 1rem; + height: 400px; + overflow-y: scroll; + margin: -1rem; + padding: 1rem; +} + +.home .demo .browser-viewport .search-header > h1 { + color: #000; + text-align: left; + font-size: 24px; + margin: 0; +} + +.home .demo .browser-viewport .search-header > p { + text-align: left; + font-size: 16px; + margin: 10px 0; +} + +.home .demo .search-bar input { + width: 100%; + background: rgba(153, 161, 179, 0.2); + border-radius: 9999px; + padding-left: 40px; + padding-right: 40px; + height: 40px; + color: #000; +} + +.home .demo .search-bar svg { + height: 40px; + position: absolute; + transform: translateX(75%); +} + +.home .demo .search-bar { + position: relative; +} + +/* Desktop Styling */ +@media screen and (min-width: 60em) { + .home .row { + text-align: center; + } + .home .row > p { + font-size: 21px; + } + .home .row > h1 { + font-size: 52px; + } + .home .row .pop-left { + margin-left: -20px; + margin-right: 0; + margin-top: -20px; + margin-bottom: -20px; + } + .home .row .pop-right { + margin-left: 0px; + margin-right: 0px; + margin-top: -20px; + margin-bottom: -20px; + } +} + +/* Mobile Styling */ +@media screen and (max-width: 60em) { + .home .row { + padding: 4rem 0.8rem; + } + .home .row > h1, + .home .row > p { + padding-left: 1rem; + padding-right: 1rem; + } + .home .row.first { + padding-top: 2rem; + } + .home-btns { + width: 100%; + display: grid; + grid-gap: 0.5rem; + gap: 0.5rem; + } + .home .example-container { + display: flex; + flex-direction: column; + row-gap: 20px; + width: 100%; + justify-content: center; + border-radius: 0; + padding: 1rem 0; + } + .home .row { + padding-left: 0; + padding-right: 0; + } + .home .tabbed-set { + width: 100%; + border-radius: 0; + } + .home .demo { + width: 100%; + display: flex; + justify-content: center; + } + .home .demo > .white-bg { + width: 80%; + max-width: 80%; + } +} diff --git a/docs/src/assets/css/main.css b/docs/src/assets/css/main.css new file mode 100644 index 000000000..6eefdf2f4 --- /dev/null +++ b/docs/src/assets/css/main.css @@ -0,0 +1,85 @@ +/* Variable overrides */ +:root { + --reactpy-color: #58b962; + --reactpy-color-dark: #42914a; + --reactpy-color-darker: #34743b; + --reactpy-color-opacity-10: rgba(88, 185, 98, 0.1); +} + +[data-md-color-accent="red"] { + --md-primary-fg-color--light: var(--reactpy-color); + --md-primary-fg-color--dark: var(--reactpy-color-dark); +} + +[data-md-color-scheme="slate"] { + --md-default-bg-color: rgb(35, 39, 47); + --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); + --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); + --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); + --md-primary-fg-color: var(--md-default-bg-color); + --md-default-fg-color: hsla(var(--md-hue), 75%, 95%, 1); + --md-default-fg-color--light: #fff; + --md-typeset-a-color: var(--reactpy-color); + --md-accent-fg-color: var(--reactpy-color-dark); +} + +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--md-default-bg-color); + --md-default-fg-color--light: #000; + --md-default-fg-color--lighter: #0000007e; + --md-default-fg-color--lightest: #00000029; + --md-typeset-color: rgb(35, 39, 47); + --md-typeset-a-color: var(--reactpy-color); + --md-accent-fg-color: var(--reactpy-color-dark); +} + +/* Font changes */ +.md-typeset { + font-weight: 300; +} + +.md-typeset h1 { + font-weight: 600; + margin: 0; + font-size: 2.5em; +} + +.md-typeset h2 { + font-weight: 500; +} + +.md-typeset h3 { + font-weight: 400; +} + +/* Intro section styling */ +p.intro { + font-size: 0.9rem; + font-weight: 500; +} + +/* Hide "Overview" jump selector */ +h2#overview { + visibility: hidden; + height: 0; + margin: 0; + padding: 0; +} + +/* Reduce size of the outdated banner */ +.md-banner__inner { + margin: 0.45rem auto; +} + +/* Desktop Styles */ +@media screen and (min-width: 60em) { + /* Remove max width on desktop */ + .md-grid { + max-width: none; + } +} + +/* Max size of page content */ +.md-content { + max-width: 56rem; +} diff --git a/docs/src/assets/css/navbar.css b/docs/src/assets/css/navbar.css new file mode 100644 index 000000000..33e8b14fd --- /dev/null +++ b/docs/src/assets/css/navbar.css @@ -0,0 +1,185 @@ +[data-md-color-scheme="slate"] { + --md-header-border-color: rgb(255 255 255 / 5%); + --md-version-bg-color: #ffffff0d; +} + +[data-md-color-scheme="default"] { + --md-header-border-color: rgb(0 0 0 / 7%); + --md-version-bg-color: #ae58ee2e; +} + +.md-header { + border: 0 solid transparent; + border-bottom-width: 1px; +} + +.md-header--shadow { + box-shadow: none; + border-color: var(--md-header-border-color); + transition: border-color 0.35s cubic-bezier(0.1, 0.7, 0.1, 1); +} + +/* Version selector */ +.md-header__topic .md-ellipsis, +.md-header__title [data-md-component="header-topic"] { + display: none; +} + +[dir="ltr"] .md-version__current { + margin: 0; +} + +.md-version__list { + margin: 0; + left: 0; + right: 0; + top: 2.5rem; +} + +.md-version { + background: var(--md-version-bg-color); + border-radius: 999px; + padding: 0 0.8rem; + margin: 0.3rem 0; + height: 1.8rem; + display: flex; + font-size: 0.7rem; +} + +/* Mobile Styling */ +@media screen and (max-width: 60em) { + label.md-header__button.md-icon[for="__drawer"] { + order: 1; + } + .md-header__button.md-logo { + display: initial; + order: 2; + margin-right: auto; + } + .md-header__title { + order: 3; + } + .md-header__button[for="__search"] { + order: 4; + } + .md-header__option[data-md-component="palette"] { + order: 5; + } + .md-header__source { + display: initial; + order: 6; + } + .md-header__source .md-source__repository { + display: none; + } +} + +/* Desktop Styling */ +@media screen and (min-width: 60em) { + /* Nav container */ + nav.md-header__inner { + display: contents; + } + header.md-header { + display: flex; + align-items: center; + } + + /* Logo */ + .md-header__button.md-logo { + order: 1; + padding-right: 0.4rem; + padding-top: 0; + padding-bottom: 0; + } + .md-header__button.md-logo img { + height: 2rem; + } + + /* Version selector */ + [dir="ltr"] .md-header__title { + order: 2; + margin: 0; + margin-right: 0.8rem; + margin-left: 0.2rem; + flex-grow: 0; + } + .md-header__topic { + position: relative; + } + .md-header__title--active .md-header__topic { + transform: none; + opacity: 1; + pointer-events: auto; + z-index: 4; + } + + /* Search */ + .md-search { + order: 3; + width: 100%; + margin-right: 0.6rem; + } + .md-search__inner { + width: 100%; + float: unset !important; + } + .md-search__form { + border-radius: 9999px; + } + [data-md-toggle="search"]:checked ~ .md-header .md-header__option { + max-width: unset; + opacity: unset; + transition: unset; + } + + /* Tabs */ + .md-tabs { + order: 4; + min-width: -webkit-fit-content; + min-width: -moz-fit-content; + min-width: fit-content; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + z-index: -1; + overflow: visible; + border: none !important; + } + li.md-tabs__item.md-tabs__item--active { + background: var(--reactpy-color-opacity-10); + border-radius: 9999px; + color: var(--md-typeset-a-color); + } + .md-tabs__link { + margin: 0; + } + .md-tabs__item { + height: 1.8rem; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + } + + /* Dark/Light Selector */ + .md-header__option[data-md-component="palette"] { + order: 5; + } + + /* GitHub info */ + .md-header__source { + order: 6; + margin-left: 0 !important; + } +} + +/* Ultrawide Desktop Styles */ +@media screen and (min-width: 1919px) { + .md-search { + order: 2; + width: 100%; + max-width: 34.4rem; + margin: 0 auto; + } +} diff --git a/docs/src/assets/css/sidebar.css b/docs/src/assets/css/sidebar.css new file mode 100644 index 000000000..b6507d963 --- /dev/null +++ b/docs/src/assets/css/sidebar.css @@ -0,0 +1,104 @@ +:root { + --sizebar-font-size: 0.62rem; +} + +.md-nav__link { + word-break: break-word; +} + +/* Desktop Styling */ +@media screen and (min-width: 76.1875em) { + /* Move the sidebar and TOC to the edge of the page */ + .md-main__inner.md-grid { + margin-left: 0; + margin-right: 0; + max-width: unset; + display: grid; + grid-template-columns: auto 1fr auto; + } + + .md-content { + justify-self: center; + width: 100%; + } + /* Made the sidebar buttons look React-like */ + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + text-transform: uppercase; + } + + .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + } + + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + color: rgb(133, 142, 159); + margin: 0.5rem; + } + + .md-nav__item .md-nav__link { + position: relative; + } + + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { + color: unset; + } + + .md-nav__item + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.2; + z-index: -1; + background: grey; + } + + .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + background: var(--reactpy-color-opacity-10); + } + + .md-nav__link { + padding: 0.5rem 0.5rem 0.5rem 1rem; + margin: 0; + border-radius: 0 10px 10px 0; + font-weight: 500; + overflow: hidden; + font-size: var(--sizebar-font-size); + } + + .md-sidebar__scrollwrap { + margin: 0; + } + + [dir="ltr"] + .md-nav--lifted + .md-nav[data-md-level="1"] + > .md-nav__list + > .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 300; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 400; + padding-left: 1.25rem; + } +} diff --git a/docs/src/assets/css/table-of-contents.css b/docs/src/assets/css/table-of-contents.css new file mode 100644 index 000000000..6c94f06ef --- /dev/null +++ b/docs/src/assets/css/table-of-contents.css @@ -0,0 +1,48 @@ +/* Table of Contents styling */ +@media screen and (min-width: 60em) { + [data-md-component="sidebar"] .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + margin-left: 0; + font-size: var(--sizebar-font-size); + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active { + position: relative; + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.15; + z-index: -1; + background: var(--md-typeset-a-color); + } + + [data-md-component="toc"] .md-nav__link { + padding: 0.5rem 0.5rem; + margin: 0; + border-radius: 10px 0 0 10px; + font-weight: 400; + } + + [data-md-component="toc"] + .md-nav__item + .md-nav__list + .md-nav__item + .md-nav__link { + padding-left: 1.25rem; + } + + [dir="ltr"] .md-sidebar__inner { + padding: 0; + } + + .md-nav__item { + padding: 0; + } +} diff --git a/docs/src/assets/images/add-interactivity.png b/docs/src/assets/images/add-interactivity.png new file mode 100644 index 000000000..c32431905 Binary files /dev/null and b/docs/src/assets/images/add-interactivity.png differ diff --git a/docs/src/assets/images/create-user-interfaces.png b/docs/src/assets/images/create-user-interfaces.png new file mode 100644 index 000000000..06f6ea0cb Binary files /dev/null and b/docs/src/assets/images/create-user-interfaces.png differ diff --git a/docs/src/assets/images/s_thinking-in-react_ui.png b/docs/src/assets/images/s_thinking-in-react_ui.png new file mode 100644 index 000000000..a3249d526 Binary files /dev/null and b/docs/src/assets/images/s_thinking-in-react_ui.png differ diff --git a/docs/src/assets/images/s_thinking-in-react_ui_outline.png b/docs/src/assets/images/s_thinking-in-react_ui_outline.png new file mode 100644 index 000000000..e705738c9 Binary files /dev/null and b/docs/src/assets/images/s_thinking-in-react_ui_outline.png differ diff --git a/docs/src/assets/images/write-components-with-python.png b/docs/src/assets/images/write-components-with-python.png new file mode 100644 index 000000000..380d2c3ad Binary files /dev/null and b/docs/src/assets/images/write-components-with-python.png differ diff --git a/docs/src/assets/js/main.js b/docs/src/assets/js/main.js new file mode 100644 index 000000000..50e2dda30 --- /dev/null +++ b/docs/src/assets/js/main.js @@ -0,0 +1,19 @@ +// Sync scrolling between the code node and the line number node +// Event needs to be a separate function, otherwise the event will be triggered multiple times +let code_with_lineno_scroll_event = function () { + let tr = this.parentNode.parentNode.parentNode.parentNode; + let lineno = tr.querySelector(".linenos"); + lineno.scrollTop = this.scrollTop; +}; + +const observer = new MutationObserver((mutations) => { + let lineno = document.querySelectorAll(".linenos~.code"); + lineno.forEach(function (element) { + let code = element.parentNode.querySelector("code"); + code.addEventListener("scroll", code_with_lineno_scroll_event); + }); +}); + +observer.observe(document.body, { + childList: true, +}); diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt new file mode 100644 index 000000000..af891a7e5 --- /dev/null +++ b/docs/src/dictionary.txt @@ -0,0 +1,28 @@ +django +nox +websocket +websockets +changelog +async +pre +prefetch +prefetching +preloader +whitespace +refetch +refetched +refetching +html +jupyter +webserver +iframe +keyworded +stylesheet +stylesheets +unstyled +py +idom +reactpy +asgi +postfixed +postprocessing diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 000000000..384ec5b6d --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,6 @@ +--- +template: home.html +hide: + - navigation + - toc +--- diff --git a/docs/src/learn/add-react-to-an-existing-project.md b/docs/src/learn/add-react-to-an-existing-project.md new file mode 100644 index 000000000..d201c1b9e --- /dev/null +++ b/docs/src/learn/add-react-to-an-existing-project.md @@ -0,0 +1,135 @@ +## Overview + +<p class="intro" markdown> + +If you want to add some interactivity to your existing project, you don't have to rewrite it in React. Add React to your existing stack, and render interactive React components anywhere. + +</p> + +## Using React for an entire subroute of your existing website + +Let's say you have an existing web app at `example.com` built with another server technology (like Rails), and you want to implement all routes starting with `example.com/some-app/` fully with React. + +### Using an ASGI subroute + +Here's how we recommend to set it up: + +1. **Build the React part of your app** using one of the [ReactPy executors](./creating-a-react-app.md). +2. **Specify `/some-app` as the _base path_** in your executors kwargs (`#!python path_prefix="/some-app"`). +3. **Configure your server or a proxy** so that all requests under `/some-app/` are handled by your React app. + +This ensures the React part of your app can [benefit from the best practices](./creating-a-react-app.md) baked into those frameworks. + +### Using static site generation ([SSG](https://developer.mozilla.org/en-US/docs/Glossary/SSG)) + +<!-- These apps can be deployed to a [CDN](https://developer.mozilla.org/en-US/docs/Glossary/CDN) or static hosting service without a server. --> + +Support for SSG is coming in a [future version](https://github.com/reactive-python/reactpy/issues/1272). + +## Using React for a part of your existing page + +Let's say you have an existing page built with another Python web technology (ASGI or WSGI), and you want to render interactive React components somewhere on that page. + +The exact approach depends on your existing page setup, so let's walk through some details. + +### Using ASGI Middleware + +ReactPy supports running as middleware for any existing ASGI application. ReactPy components are embedded into your existing HTML templates using Jinja2. You can use any ASGI framework, however for demonstration purposes we have selected [Starlette](https://www.starlette.io/) for the example below. + +First, install ReactPy, Starlette, and your preferred ASGI webserver. + +!!! example "Terminal" + + ```linenums="0" + pip install reactpy[asgi,jinja] starlette uvicorn[standard] + ``` + +Next, configure your ASGI framework to use ReactPy's Jinja2 template tag. The method for doing this will vary depending on the ASGI framework you are using. Below is an example that follow's [Starlette's documentation](https://www.starlette.io/templates/): + +```python linenums="0" hl_lines="6 11 17" +{% include "../../examples/add_react_to_an_existing_project/asgi_configure_jinja.py" %} +``` + +Now you will need to wrap your existing ASGI application with ReactPy's middleware, define the dotted path to your root components, and render your components in your existing HTML templates. + +!!! abstract "Note" + + The `ReactPyJinja` extension enables a handful of [template tags](/reference/templatetags/) that allow you to render ReactPy components in your templates. The `component` tag is used to render a ReactPy SSR component, while the `pyscript_setup` and `pyscript_component` tags can be used together to render CSR components. + +=== "main.py" + + ```python hl_lines="6 22" + {% include "../../examples/add_react_to_an_existing_project/asgi_middleware.py" %} + ``` + +=== "my_components.py" + + ```python + {% include "../../examples/add_react_to_an_existing_project/asgi_component.py" %} + ``` + +=== "my_template.html" + + ```html hl_lines="5 9" + {% include "../../examples/add_react_to_an_existing_project/asgi_template.html" %} + ``` + +Finally, use your webserver of choice to start ReactPy: + +!!! example "Terminal" + + ```linenums="0" + uvicorn main:reactpy_app + ``` + +### Using WSGI Middleware + +Support for WSGI executors is coming in a [future version](https://github.com/reactive-python/reactpy/issues/1260). + +## External Executors + +!!! abstract "Note" + + **External executors** exist outside ReactPy's core library and have significantly different installation and configuration instructions. + + Make sure to follow the documentation for setting up your chosen _external_ executor. + +### Django + +[Django](https://www.djangoproject.com/) is a full-featured web framework that provides a batteries-included approach to web development. + +Due to it's batteries-included approach, ReactPy has unique features only available to this executor. + +To learn how to configure Django for ReactPy, see the [ReactPy-Django documentation](https://reactive-python.github.io/reactpy-django/). + +<!-- +TODO: Fix reactpy-jupyter +### Jupyter + +Jupyter is an interactive computing environment that is used for data science and machine learning. It allows users to run code, visualize data, and collaborate with others in a live environment. Jupyter is a powerful tool for data scientists and machine learning engineers. + +!!! example "Terminal" + + ```linenums="0" + pip install reactpy-jupyter + ``` + +If you're new to Jupyter, check out the [Jupyter tutorial](https://jupyter.org/try). + +ReactPy has unique [configuration instructions](https://github.com/reactive-python/reactpy-jupyter#readme) to use Jupyter. --> + +<!-- +TODO: Fix reactpy-dash +### Plotly Dash + +Plotly Dash is a web application framework that is used to create interactive dashboards. It allows users to create dashboards that can be used to visualize data and interact with it in real time. Plotly Dash is a good choice for creating dashboards that need to be interactive and informative. + +!!! example "Terminal" + + ```linenums="0" + pip install reactpy-dash + ``` + +If you're new to Plotly Dash, check out the [Plotly Dash tutorial](https://dash.plotly.com/installation). + +ReactPy has unique [configuration instructions](https://github.com/reactive-python/reactpy-dash#readme) to use Plotly Dash. --> diff --git a/docs/src/learn/choosing-the-state-structure.md b/docs/src/learn/choosing-the-state-structure.md new file mode 100644 index 000000000..c582becee --- /dev/null +++ b/docs/src/learn/choosing-the-state-structure.md @@ -0,0 +1,2862 @@ +## Overview + +<p class="intro" markdown> + +Structuring state well can make a difference between a component that is pleasant to modify and debug, and one that is a constant source of bugs. Here are some tips you should consider when structuring state. + +</p> + +!!! summary "You will learn" + + - When to use a single vs multiple state variables + - What to avoid when organizing state + - How to fix common issues with the state structure + +## Principles for structuring state + +When you write a component that holds some state, you'll have to make choices about how many state variables to use and what the shape of their data should be. While it's possible to write correct programs even with a suboptimal state structure, there are a few principles that can guide you to make better choices: + +1. **Group related state.** If you always update two or more state variables at the same time, consider merging them into a single state variable. +2. **Avoid contradictions in state.** When the state is structured in a way that several pieces of state may contradict and "disagree" with each other, you leave room for mistakes. Try to avoid this. +3. **Avoid redundant state.** If you can calculate some information from the component's props or its existing state variables during rendering, you should not put that information into that component's state. +4. **Avoid duplication in state.** When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync. Reduce duplication when you can. +5. **Avoid deeply nested state.** Deeply hierarchical state is not very convenient to update. When possible, prefer to structure state in a flat way. + +The goal behind these principles is to _make state easy to update without introducing mistakes_. Removing redundant and duplicate data from state helps ensure that all its pieces stay in sync. This is similar to how a database engineer might want to ["normalize" the database structure](https://docs.microsoft.com/en-us/office/troubleshoot/access/database-normalization-description) to reduce the chance of bugs. To paraphrase Albert Einstein, **"Make your state as simple as it can be--but no simpler."** + +Now let's see how these principles apply in action. + +## Group related state + +You might sometimes be unsure between using a single or multiple state variables. + +Should you do this? + +```js +const [x, setX] = useState(0); +const [y, setY] = useState(0); +``` + +Or this? + +```js +const [position, setPosition] = useState({ x: 0, y: 0 }); +``` + +Technically, you can use either of these approaches. But **if some two state variables always change together, it might be a good idea to unify them into a single state variable.** Then you won't forget to always keep them in sync, like in this example where moving the cursor updates both coordinates of the red dot: + +```js +import { useState } from "react"; + +export default function MovingDot() { + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + return ( + <div + onPointerMove={(e) => { + setPosition({ + x: e.clientX, + y: e.clientY, + }); + }} + style={{ + position: "relative", + width: "100vw", + height: "100vh", + }} + > + <div + style={{ + position: "absolute", + backgroundColor: "red", + borderRadius: "50%", + transform: `translate(${position.x}px, ${position.y}px)`, + left: -10, + top: -10, + width: 20, + height: 20, + }} + /> + </div> + ); +} +``` + +```css +body { + margin: 0; + padding: 0; + height: 250px; +} +``` + +Another case where you'll group data into an object or an array is when you don't know how many pieces of state you'll need. For example, it's helpful when you have a form where the user can add custom fields. + +<Pitfall> + +If your state variable is an object, remember that [you can't update only one field in it](/learn/updating-objects-in-state) without explicitly copying the other fields. For example, you can't do `setPosition({ x: 100 })` in the above example because it would not have the `y` property at all! Instead, if you wanted to set `x` alone, you would either do `setPosition({ ...position, x: 100 })`, or split them into two state variables and do `setX(100)`. + +</Pitfall> + +## Avoid contradictions in state + +Here is a hotel feedback form with `isSending` and `isSent` state variables: + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + const [text, setText] = useState(""); + const [isSending, setIsSending] = useState(false); + const [isSent, setIsSent] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setIsSending(true); + await sendMessage(text); + setIsSending(false); + setIsSent(true); + } + + if (isSent) { + return <h1>Thanks for feedback!</h1>; + } + + return ( + <form onSubmit={handleSubmit}> + <p>How was your stay at The Prancing Pony?</p> + <textarea + disabled={isSending} + value={text} + onChange={(e) => setText(e.target.value)} + /> + <br /> + <button disabled={isSending} type="submit"> + Send + </button> + {isSending && <p>Sending...</p>} + </form> + ); +} + +// Pretend to send a message. +function sendMessage(text) { + return new Promise((resolve) => { + setTimeout(resolve, 2000); + }); +} +``` + +While this code works, it leaves the door open for "impossible" states. For example, if you forget to call `setIsSent` and `setIsSending` together, you may end up in a situation where both `isSending` and `isSent` are `true` at the same time. The more complex your component is, the harder it is to understand what happened. + +**Since `isSending` and `isSent` should never be `true` at the same time, it is better to replace them with one `status` state variable that may take one of _three_ valid states:** `'typing'` (initial), `'sending'`, and `'sent'`: + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + const [text, setText] = useState(""); + const [status, setStatus] = useState("typing"); + + async function handleSubmit(e) { + e.preventDefault(); + setStatus("sending"); + await sendMessage(text); + setStatus("sent"); + } + + const isSending = status === "sending"; + const isSent = status === "sent"; + + if (isSent) { + return <h1>Thanks for feedback!</h1>; + } + + return ( + <form onSubmit={handleSubmit}> + <p>How was your stay at The Prancing Pony?</p> + <textarea + disabled={isSending} + value={text} + onChange={(e) => setText(e.target.value)} + /> + <br /> + <button disabled={isSending} type="submit"> + Send + </button> + {isSending && <p>Sending...</p>} + </form> + ); +} + +// Pretend to send a message. +function sendMessage(text) { + return new Promise((resolve) => { + setTimeout(resolve, 2000); + }); +} +``` + +You can still declare some constants for readability: + +```js +const isSending = status === "sending"; +const isSent = status === "sent"; +``` + +But they're not state variables, so you don't need to worry about them getting out of sync with each other. + +## Avoid redundant state + +If you can calculate some information from the component's props or its existing state variables during rendering, you **should not** put that information into that component's state. + +For example, take this form. It works, but can you find any redundant state in it? + +```js +import { useState } from "react"; + +export default function Form() { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [fullName, setFullName] = useState(""); + + function handleFirstNameChange(e) { + setFirstName(e.target.value); + setFullName(e.target.value + " " + lastName); + } + + function handleLastNameChange(e) { + setLastName(e.target.value); + setFullName(firstName + " " + e.target.value); + } + + return ( + <> + <h2>Let’s check you in</h2> + <label> + First name:{" "} + <input value={firstName} onChange={handleFirstNameChange} /> + </label> + <label> + Last name:{" "} + <input value={lastName} onChange={handleLastNameChange} /> + </label> + <p> + Your ticket will be issued to: <b>{fullName}</b> + </p> + </> + ); +} +``` + +```css +label { + display: block; + margin-bottom: 5px; +} +``` + +This form has three state variables: `firstName`, `lastName`, and `fullName`. However, `fullName` is redundant. **You can always calculate `fullName` from `firstName` and `lastName` during render, so remove it from state.** + +This is how you can do it: + +```js +import { useState } from "react"; + +export default function Form() { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + + const fullName = firstName + " " + lastName; + + function handleFirstNameChange(e) { + setFirstName(e.target.value); + } + + function handleLastNameChange(e) { + setLastName(e.target.value); + } + + return ( + <> + <h2>Let’s check you in</h2> + <label> + First name:{" "} + <input value={firstName} onChange={handleFirstNameChange} /> + </label> + <label> + Last name:{" "} + <input value={lastName} onChange={handleLastNameChange} /> + </label> + <p> + Your ticket will be issued to: <b>{fullName}</b> + </p> + </> + ); +} +``` + +```css +label { + display: block; + margin-bottom: 5px; +} +``` + +Here, `fullName` is _not_ a state variable. Instead, it's calculated during render: + +```js +const fullName = firstName + " " + lastName; +``` + +As a result, the change handlers don't need to do anything special to update it. When you call `setFirstName` or `setLastName`, you trigger a re-render, and then the next `fullName` will be calculated from the fresh data. + +<DeepDive> + +#### Don't mirror props in state + +A common example of redundant state is code like this: + +```js +function Message({ messageColor }) { + const [color, setColor] = useState(messageColor); +``` + +Here, a `color` state variable is initialized to the `messageColor` prop. The problem is that **if the parent component passes a different value of `messageColor` later (for example, `'red'` instead of `'blue'`), the `color` _state variable_ would not be updated!** The state is only initialized during the first render. + +This is why "mirroring" some prop in a state variable can lead to confusion. Instead, use the `messageColor` prop directly in your code. If you want to give it a shorter name, use a constant: + +```js +function Message({ messageColor }) { + const color = messageColor; +``` + +This way it won't get out of sync with the prop passed from the parent component. + +"Mirroring" props into state only makes sense when you _want_ to ignore all updates for a specific prop. By convention, start the prop name with `initial` or `default` to clarify that its new values are ignored: + +```js +function Message({ initialColor }) { + // The `color` state variable holds the *first* value of `initialColor`. + // Further changes to the `initialColor` prop are ignored. + const [color, setColor] = useState(initialColor); +``` + +</DeepDive> + +## Avoid duplication in state + +This menu list component lets you choose a single travel snack out of several: + +```js +import { useState } from "react"; + +const initialItems = [ + { title: "pretzels", id: 0 }, + { title: "crispy seaweed", id: 1 }, + { title: "granola bar", id: 2 }, +]; + +export default function Menu() { + const [items, setItems] = useState(initialItems); + const [selectedItem, setSelectedItem] = useState(items[0]); + + return ( + <> + <h2>What's your travel snack?</h2> + <ul> + {items.map((item) => ( + <li key={item.id}> + {item.title}{" "} + <button + on_click={() => { + setSelectedItem(item); + }} + > + Choose + </button> + </li> + ))} + </ul> + <p>You picked {selectedItem.title}.</p> + </> + ); +} +``` + +```css +button { + margin-top: 10px; +} +``` + +Currently, it stores the selected item as an object in the `selectedItem` state variable. However, this is not great: **the contents of the `selectedItem` is the same object as one of the items inside the `items` list.** This means that the information about the item itself is duplicated in two places. + +Why is this a problem? Let's make each item editable: + +```js +import { useState } from "react"; + +const initialItems = [ + { title: "pretzels", id: 0 }, + { title: "crispy seaweed", id: 1 }, + { title: "granola bar", id: 2 }, +]; + +export default function Menu() { + const [items, setItems] = useState(initialItems); + const [selectedItem, setSelectedItem] = useState(items[0]); + + function handleItemChange(id, e) { + setItems( + items.map((item) => { + if (item.id === id) { + return { + ...item, + title: e.target.value, + }; + } else { + return item; + } + }) + ); + } + + return ( + <> + <h2>What's your travel snack?</h2> + <ul> + {items.map((item, index) => ( + <li key={item.id}> + <input + value={item.title} + onChange={(e) => { + handleItemChange(item.id, e); + }} + />{" "} + <button + on_click={() => { + setSelectedItem(item); + }} + > + Choose + </button> + </li> + ))} + </ul> + <p>You picked {selectedItem.title}.</p> + </> + ); +} +``` + +```css +button { + margin-top: 10px; +} +``` + +Notice how if you first click "Choose" on an item and _then_ edit it, **the input updates but the label at the bottom does not reflect the edits.** This is because you have duplicated state, and you forgot to update `selectedItem`. + +Although you could update `selectedItem` too, an easier fix is to remove duplication. In this example, instead of a `selectedItem` object (which creates a duplication with objects inside `items`), you hold the `selectedId` in state, and _then_ get the `selectedItem` by searching the `items` array for an item with that ID: + +```js +import { useState } from "react"; + +const initialItems = [ + { title: "pretzels", id: 0 }, + { title: "crispy seaweed", id: 1 }, + { title: "granola bar", id: 2 }, +]; + +export default function Menu() { + const [items, setItems] = useState(initialItems); + const [selectedId, setSelectedId] = useState(0); + + const selectedItem = items.find((item) => item.id === selectedId); + + function handleItemChange(id, e) { + setItems( + items.map((item) => { + if (item.id === id) { + return { + ...item, + title: e.target.value, + }; + } else { + return item; + } + }) + ); + } + + return ( + <> + <h2>What's your travel snack?</h2> + <ul> + {items.map((item, index) => ( + <li key={item.id}> + <input + value={item.title} + onChange={(e) => { + handleItemChange(item.id, e); + }} + />{" "} + <button + on_click={() => { + setSelectedId(item.id); + }} + > + Choose + </button> + </li> + ))} + </ul> + <p>You picked {selectedItem.title}.</p> + </> + ); +} +``` + +```css +button { + margin-top: 10px; +} +``` + +(Alternatively, you may hold the selected index in state.) + +The state used to be duplicated like this: + +- `items = [{ id: 0, title: 'pretzels'}, ...]` +- `selectedItem = {id: 0, title: 'pretzels'}` + +But after the change it's like this: + +- `items = [{ id: 0, title: 'pretzels'}, ...]` +- `selectedId = 0` + +The duplication is gone, and you only keep the essential state! + +Now if you edit the _selected_ item, the message below will update immediately. This is because `setItems` triggers a re-render, and `items.find(...)` would find the item with the updated title. You didn't need to hold _the selected item_ in state, because only the _selected ID_ is essential. The rest could be calculated during render. + +## Avoid deeply nested state + +Imagine a travel plan consisting of planets, continents, and countries. You might be tempted to structure its state using nested objects and arrays, like in this example: + +```js +import { useState } from "react"; +import { initialTravelPlan } from "./places.js"; + +function PlaceTree({ place }) { + const childPlaces = place.childPlaces; + return ( + <li> + {place.title} + {childPlaces.length > 0 && ( + <ol> + {childPlaces.map((place) => ( + <PlaceTree key={place.id} place={place} /> + ))} + </ol> + )} + </li> + ); +} + +export default function TravelPlan() { + const [plan, setPlan] = useState(initialTravelPlan); + const planets = plan.childPlaces; + return ( + <> + <h2>Places to visit</h2> + <ol> + {planets.map((place) => ( + <PlaceTree key={place.id} place={place} /> + ))} + </ol> + </> + ); +} +``` + +```js +export const initialTravelPlan = { + id: 0, + title: "(Root)", + childPlaces: [ + { + id: 1, + title: "Earth", + childPlaces: [ + { + id: 2, + title: "Africa", + childPlaces: [ + { + id: 3, + title: "Botswana", + childPlaces: [], + }, + { + id: 4, + title: "Egypt", + childPlaces: [], + }, + { + id: 5, + title: "Kenya", + childPlaces: [], + }, + { + id: 6, + title: "Madagascar", + childPlaces: [], + }, + { + id: 7, + title: "Morocco", + childPlaces: [], + }, + { + id: 8, + title: "Nigeria", + childPlaces: [], + }, + { + id: 9, + title: "South Africa", + childPlaces: [], + }, + ], + }, + { + id: 10, + title: "Americas", + childPlaces: [ + { + id: 11, + title: "Argentina", + childPlaces: [], + }, + { + id: 12, + title: "Brazil", + childPlaces: [], + }, + { + id: 13, + title: "Barbados", + childPlaces: [], + }, + { + id: 14, + title: "Canada", + childPlaces: [], + }, + { + id: 15, + title: "Jamaica", + childPlaces: [], + }, + { + id: 16, + title: "Mexico", + childPlaces: [], + }, + { + id: 17, + title: "Trinidad and Tobago", + childPlaces: [], + }, + { + id: 18, + title: "Venezuela", + childPlaces: [], + }, + ], + }, + { + id: 19, + title: "Asia", + childPlaces: [ + { + id: 20, + title: "China", + childPlaces: [], + }, + { + id: 21, + title: "Hong Kong", + childPlaces: [], + }, + { + id: 22, + title: "India", + childPlaces: [], + }, + { + id: 23, + title: "Singapore", + childPlaces: [], + }, + { + id: 24, + title: "South Korea", + childPlaces: [], + }, + { + id: 25, + title: "Thailand", + childPlaces: [], + }, + { + id: 26, + title: "Vietnam", + childPlaces: [], + }, + ], + }, + { + id: 27, + title: "Europe", + childPlaces: [ + { + id: 28, + title: "Croatia", + childPlaces: [], + }, + { + id: 29, + title: "France", + childPlaces: [], + }, + { + id: 30, + title: "Germany", + childPlaces: [], + }, + { + id: 31, + title: "Italy", + childPlaces: [], + }, + { + id: 32, + title: "Portugal", + childPlaces: [], + }, + { + id: 33, + title: "Spain", + childPlaces: [], + }, + { + id: 34, + title: "Turkey", + childPlaces: [], + }, + ], + }, + { + id: 35, + title: "Oceania", + childPlaces: [ + { + id: 36, + title: "Australia", + childPlaces: [], + }, + { + id: 37, + title: "Bora Bora (French Polynesia)", + childPlaces: [], + }, + { + id: 38, + title: "Easter Island (Chile)", + childPlaces: [], + }, + { + id: 39, + title: "Fiji", + childPlaces: [], + }, + { + id: 40, + title: "Hawaii (the USA)", + childPlaces: [], + }, + { + id: 41, + title: "New Zealand", + childPlaces: [], + }, + { + id: 42, + title: "Vanuatu", + childPlaces: [], + }, + ], + }, + ], + }, + { + id: 43, + title: "Moon", + childPlaces: [ + { + id: 44, + title: "Rheita", + childPlaces: [], + }, + { + id: 45, + title: "Piccolomini", + childPlaces: [], + }, + { + id: 46, + title: "Tycho", + childPlaces: [], + }, + ], + }, + { + id: 47, + title: "Mars", + childPlaces: [ + { + id: 48, + title: "Corn Town", + childPlaces: [], + }, + { + id: 49, + title: "Green Hill", + childPlaces: [], + }, + ], + }, + ], +}; +``` + +Now let's say you want to add a button to delete a place you've already visited. How would you go about it? [Updating nested state](/learn/updating-objects-in-state#updating-a-nested-object) involves making copies of objects all the way up from the part that changed. Deleting a deeply nested place would involve copying its entire parent place chain. Such code can be very verbose. + +**If the state is too nested to update easily, consider making it "flat".** Here is one way you can restructure this data. Instead of a tree-like structure where each `place` has an array of _its child places_, you can have each place hold an array of _its child place IDs_. Then store a mapping from each place ID to the corresponding place. + +This data restructuring might remind you of seeing a database table: + +```js +import { useState } from "react"; +import { initialTravelPlan } from "./places.js"; + +function PlaceTree({ id, placesById }) { + const place = placesById[id]; + const childIds = place.childIds; + return ( + <li> + {place.title} + {childIds.length > 0 && ( + <ol> + {childIds.map((childId) => ( + <PlaceTree + key={childId} + id={childId} + placesById={placesById} + /> + ))} + </ol> + )} + </li> + ); +} + +export default function TravelPlan() { + const [plan, setPlan] = useState(initialTravelPlan); + const root = plan[0]; + const planetIds = root.childIds; + return ( + <> + <h2>Places to visit</h2> + <ol> + {planetIds.map((id) => ( + <PlaceTree key={id} id={id} placesById={plan} /> + ))} + </ol> + </> + ); +} +``` + +```js +export const initialTravelPlan = { + 0: { + id: 0, + title: "(Root)", + childIds: [1, 43, 47], + }, + 1: { + id: 1, + title: "Earth", + childIds: [2, 10, 19, 27, 35], + }, + 2: { + id: 2, + title: "Africa", + childIds: [3, 4, 5, 6, 7, 8, 9], + }, + 3: { + id: 3, + title: "Botswana", + childIds: [], + }, + 4: { + id: 4, + title: "Egypt", + childIds: [], + }, + 5: { + id: 5, + title: "Kenya", + childIds: [], + }, + 6: { + id: 6, + title: "Madagascar", + childIds: [], + }, + 7: { + id: 7, + title: "Morocco", + childIds: [], + }, + 8: { + id: 8, + title: "Nigeria", + childIds: [], + }, + 9: { + id: 9, + title: "South Africa", + childIds: [], + }, + 10: { + id: 10, + title: "Americas", + childIds: [11, 12, 13, 14, 15, 16, 17, 18], + }, + 11: { + id: 11, + title: "Argentina", + childIds: [], + }, + 12: { + id: 12, + title: "Brazil", + childIds: [], + }, + 13: { + id: 13, + title: "Barbados", + childIds: [], + }, + 14: { + id: 14, + title: "Canada", + childIds: [], + }, + 15: { + id: 15, + title: "Jamaica", + childIds: [], + }, + 16: { + id: 16, + title: "Mexico", + childIds: [], + }, + 17: { + id: 17, + title: "Trinidad and Tobago", + childIds: [], + }, + 18: { + id: 18, + title: "Venezuela", + childIds: [], + }, + 19: { + id: 19, + title: "Asia", + childIds: [20, 21, 22, 23, 24, 25, 26], + }, + 20: { + id: 20, + title: "China", + childIds: [], + }, + 21: { + id: 21, + title: "Hong Kong", + childIds: [], + }, + 22: { + id: 22, + title: "India", + childIds: [], + }, + 23: { + id: 23, + title: "Singapore", + childIds: [], + }, + 24: { + id: 24, + title: "South Korea", + childIds: [], + }, + 25: { + id: 25, + title: "Thailand", + childIds: [], + }, + 26: { + id: 26, + title: "Vietnam", + childIds: [], + }, + 27: { + id: 27, + title: "Europe", + childIds: [28, 29, 30, 31, 32, 33, 34], + }, + 28: { + id: 28, + title: "Croatia", + childIds: [], + }, + 29: { + id: 29, + title: "France", + childIds: [], + }, + 30: { + id: 30, + title: "Germany", + childIds: [], + }, + 31: { + id: 31, + title: "Italy", + childIds: [], + }, + 32: { + id: 32, + title: "Portugal", + childIds: [], + }, + 33: { + id: 33, + title: "Spain", + childIds: [], + }, + 34: { + id: 34, + title: "Turkey", + childIds: [], + }, + 35: { + id: 35, + title: "Oceania", + childIds: [36, 37, 38, 39, 40, 41, 42], + }, + 36: { + id: 36, + title: "Australia", + childIds: [], + }, + 37: { + id: 37, + title: "Bora Bora (French Polynesia)", + childIds: [], + }, + 38: { + id: 38, + title: "Easter Island (Chile)", + childIds: [], + }, + 39: { + id: 39, + title: "Fiji", + childIds: [], + }, + 40: { + id: 40, + title: "Hawaii (the USA)", + childIds: [], + }, + 41: { + id: 41, + title: "New Zealand", + childIds: [], + }, + 42: { + id: 42, + title: "Vanuatu", + childIds: [], + }, + 43: { + id: 43, + title: "Moon", + childIds: [44, 45, 46], + }, + 44: { + id: 44, + title: "Rheita", + childIds: [], + }, + 45: { + id: 45, + title: "Piccolomini", + childIds: [], + }, + 46: { + id: 46, + title: "Tycho", + childIds: [], + }, + 47: { + id: 47, + title: "Mars", + childIds: [48, 49], + }, + 48: { + id: 48, + title: "Corn Town", + childIds: [], + }, + 49: { + id: 49, + title: "Green Hill", + childIds: [], + }, +}; +``` + +**Now that the state is "flat" (also known as "normalized"), updating nested items becomes easier.** + +In order to remove a place now, you only need to update two levels of state: + +- The updated version of its _parent_ place should exclude the removed ID from its `childIds` array. +- The updated version of the root "table" object should include the updated version of the parent place. + +Here is an example of how you could go about it: + +```js +import { useState } from "react"; +import { initialTravelPlan } from "./places.js"; + +export default function TravelPlan() { + const [plan, setPlan] = useState(initialTravelPlan); + + function handleComplete(parentId, childId) { + const parent = plan[parentId]; + // Create a new version of the parent place + // that doesn't include this child ID. + const nextParent = { + ...parent, + childIds: parent.childIds.filter((id) => id !== childId), + }; + // Update the root state object... + setPlan({ + ...plan, + // ...so that it has the updated parent. + [parentId]: nextParent, + }); + } + + const root = plan[0]; + const planetIds = root.childIds; + return ( + <> + <h2>Places to visit</h2> + <ol> + {planetIds.map((id) => ( + <PlaceTree + key={id} + id={id} + parentId={0} + placesById={plan} + onComplete={handleComplete} + /> + ))} + </ol> + </> + ); +} + +function PlaceTree({ id, parentId, placesById, onComplete }) { + const place = placesById[id]; + const childIds = place.childIds; + return ( + <li> + {place.title} + <button + on_click={() => { + onComplete(parentId, id); + }} + > + Complete + </button> + {childIds.length > 0 && ( + <ol> + {childIds.map((childId) => ( + <PlaceTree + key={childId} + id={childId} + parentId={id} + placesById={placesById} + onComplete={onComplete} + /> + ))} + </ol> + )} + </li> + ); +} +``` + +```js +export const initialTravelPlan = { + 0: { + id: 0, + title: "(Root)", + childIds: [1, 43, 47], + }, + 1: { + id: 1, + title: "Earth", + childIds: [2, 10, 19, 27, 35], + }, + 2: { + id: 2, + title: "Africa", + childIds: [3, 4, 5, 6, 7, 8, 9], + }, + 3: { + id: 3, + title: "Botswana", + childIds: [], + }, + 4: { + id: 4, + title: "Egypt", + childIds: [], + }, + 5: { + id: 5, + title: "Kenya", + childIds: [], + }, + 6: { + id: 6, + title: "Madagascar", + childIds: [], + }, + 7: { + id: 7, + title: "Morocco", + childIds: [], + }, + 8: { + id: 8, + title: "Nigeria", + childIds: [], + }, + 9: { + id: 9, + title: "South Africa", + childIds: [], + }, + 10: { + id: 10, + title: "Americas", + childIds: [11, 12, 13, 14, 15, 16, 17, 18], + }, + 11: { + id: 11, + title: "Argentina", + childIds: [], + }, + 12: { + id: 12, + title: "Brazil", + childIds: [], + }, + 13: { + id: 13, + title: "Barbados", + childIds: [], + }, + 14: { + id: 14, + title: "Canada", + childIds: [], + }, + 15: { + id: 15, + title: "Jamaica", + childIds: [], + }, + 16: { + id: 16, + title: "Mexico", + childIds: [], + }, + 17: { + id: 17, + title: "Trinidad and Tobago", + childIds: [], + }, + 18: { + id: 18, + title: "Venezuela", + childIds: [], + }, + 19: { + id: 19, + title: "Asia", + childIds: [20, 21, 22, 23, 24, 25, 26], + }, + 20: { + id: 20, + title: "China", + childIds: [], + }, + 21: { + id: 21, + title: "Hong Kong", + childIds: [], + }, + 22: { + id: 22, + title: "India", + childIds: [], + }, + 23: { + id: 23, + title: "Singapore", + childIds: [], + }, + 24: { + id: 24, + title: "South Korea", + childIds: [], + }, + 25: { + id: 25, + title: "Thailand", + childIds: [], + }, + 26: { + id: 26, + title: "Vietnam", + childIds: [], + }, + 27: { + id: 27, + title: "Europe", + childIds: [28, 29, 30, 31, 32, 33, 34], + }, + 28: { + id: 28, + title: "Croatia", + childIds: [], + }, + 29: { + id: 29, + title: "France", + childIds: [], + }, + 30: { + id: 30, + title: "Germany", + childIds: [], + }, + 31: { + id: 31, + title: "Italy", + childIds: [], + }, + 32: { + id: 32, + title: "Portugal", + childIds: [], + }, + 33: { + id: 33, + title: "Spain", + childIds: [], + }, + 34: { + id: 34, + title: "Turkey", + childIds: [], + }, + 35: { + id: 35, + title: "Oceania", + childIds: [36, 37, 38, 39, 40, 41, , 42], + }, + 36: { + id: 36, + title: "Australia", + childIds: [], + }, + 37: { + id: 37, + title: "Bora Bora (French Polynesia)", + childIds: [], + }, + 38: { + id: 38, + title: "Easter Island (Chile)", + childIds: [], + }, + 39: { + id: 39, + title: "Fiji", + childIds: [], + }, + 40: { + id: 40, + title: "Hawaii (the USA)", + childIds: [], + }, + 41: { + id: 41, + title: "New Zealand", + childIds: [], + }, + 42: { + id: 42, + title: "Vanuatu", + childIds: [], + }, + 43: { + id: 43, + title: "Moon", + childIds: [44, 45, 46], + }, + 44: { + id: 44, + title: "Rheita", + childIds: [], + }, + 45: { + id: 45, + title: "Piccolomini", + childIds: [], + }, + 46: { + id: 46, + title: "Tycho", + childIds: [], + }, + 47: { + id: 47, + title: "Mars", + childIds: [48, 49], + }, + 48: { + id: 48, + title: "Corn Town", + childIds: [], + }, + 49: { + id: 49, + title: "Green Hill", + childIds: [], + }, +}; +``` + +```css +button { + margin: 10px; +} +``` + +You can nest state as much as you like, but making it "flat" can solve numerous problems. It makes state easier to update, and it helps ensure you don't have duplication in different parts of a nested object. + +<DeepDive> + +#### Improving memory usage + +Ideally, you would also remove the deleted items (and their children!) from the "table" object to improve memory usage. This version does that. It also [uses Immer](/learn/updating-objects-in-state#write-concise-update-logic-with-immer) to make the update logic more concise. + +```js +import { useImmer } from "use-immer"; +import { initialTravelPlan } from "./places.js"; + +export default function TravelPlan() { + const [plan, updatePlan] = useImmer(initialTravelPlan); + + function handleComplete(parentId, childId) { + updatePlan((draft) => { + // Remove from the parent place's child IDs. + const parent = draft[parentId]; + parent.childIds = parent.childIds.filter((id) => id !== childId); + + // Forget this place and all its subtree. + deleteAllChildren(childId); + function deleteAllChildren(id) { + const place = draft[id]; + place.childIds.forEach(deleteAllChildren); + delete draft[id]; + } + }); + } + + const root = plan[0]; + const planetIds = root.childIds; + return ( + <> + <h2>Places to visit</h2> + <ol> + {planetIds.map((id) => ( + <PlaceTree + key={id} + id={id} + parentId={0} + placesById={plan} + onComplete={handleComplete} + /> + ))} + </ol> + </> + ); +} + +function PlaceTree({ id, parentId, placesById, onComplete }) { + const place = placesById[id]; + const childIds = place.childIds; + return ( + <li> + {place.title} + <button + on_click={() => { + onComplete(parentId, id); + }} + > + Complete + </button> + {childIds.length > 0 && ( + <ol> + {childIds.map((childId) => ( + <PlaceTree + key={childId} + id={childId} + parentId={id} + placesById={placesById} + onComplete={onComplete} + /> + ))} + </ol> + )} + </li> + ); +} +``` + +```js +export const initialTravelPlan = { + 0: { + id: 0, + title: "(Root)", + childIds: [1, 43, 47], + }, + 1: { + id: 1, + title: "Earth", + childIds: [2, 10, 19, 27, 35], + }, + 2: { + id: 2, + title: "Africa", + childIds: [3, 4, 5, 6, 7, 8, 9], + }, + 3: { + id: 3, + title: "Botswana", + childIds: [], + }, + 4: { + id: 4, + title: "Egypt", + childIds: [], + }, + 5: { + id: 5, + title: "Kenya", + childIds: [], + }, + 6: { + id: 6, + title: "Madagascar", + childIds: [], + }, + 7: { + id: 7, + title: "Morocco", + childIds: [], + }, + 8: { + id: 8, + title: "Nigeria", + childIds: [], + }, + 9: { + id: 9, + title: "South Africa", + childIds: [], + }, + 10: { + id: 10, + title: "Americas", + childIds: [11, 12, 13, 14, 15, 16, 17, 18], + }, + 11: { + id: 11, + title: "Argentina", + childIds: [], + }, + 12: { + id: 12, + title: "Brazil", + childIds: [], + }, + 13: { + id: 13, + title: "Barbados", + childIds: [], + }, + 14: { + id: 14, + title: "Canada", + childIds: [], + }, + 15: { + id: 15, + title: "Jamaica", + childIds: [], + }, + 16: { + id: 16, + title: "Mexico", + childIds: [], + }, + 17: { + id: 17, + title: "Trinidad and Tobago", + childIds: [], + }, + 18: { + id: 18, + title: "Venezuela", + childIds: [], + }, + 19: { + id: 19, + title: "Asia", + childIds: [20, 21, 22, 23, 24, 25, 26], + }, + 20: { + id: 20, + title: "China", + childIds: [], + }, + 21: { + id: 21, + title: "Hong Kong", + childIds: [], + }, + 22: { + id: 22, + title: "India", + childIds: [], + }, + 23: { + id: 23, + title: "Singapore", + childIds: [], + }, + 24: { + id: 24, + title: "South Korea", + childIds: [], + }, + 25: { + id: 25, + title: "Thailand", + childIds: [], + }, + 26: { + id: 26, + title: "Vietnam", + childIds: [], + }, + 27: { + id: 27, + title: "Europe", + childIds: [28, 29, 30, 31, 32, 33, 34], + }, + 28: { + id: 28, + title: "Croatia", + childIds: [], + }, + 29: { + id: 29, + title: "France", + childIds: [], + }, + 30: { + id: 30, + title: "Germany", + childIds: [], + }, + 31: { + id: 31, + title: "Italy", + childIds: [], + }, + 32: { + id: 32, + title: "Portugal", + childIds: [], + }, + 33: { + id: 33, + title: "Spain", + childIds: [], + }, + 34: { + id: 34, + title: "Turkey", + childIds: [], + }, + 35: { + id: 35, + title: "Oceania", + childIds: [36, 37, 38, 39, 40, 41, , 42], + }, + 36: { + id: 36, + title: "Australia", + childIds: [], + }, + 37: { + id: 37, + title: "Bora Bora (French Polynesia)", + childIds: [], + }, + 38: { + id: 38, + title: "Easter Island (Chile)", + childIds: [], + }, + 39: { + id: 39, + title: "Fiji", + childIds: [], + }, + 40: { + id: 40, + title: "Hawaii (the USA)", + childIds: [], + }, + 41: { + id: 41, + title: "New Zealand", + childIds: [], + }, + 42: { + id: 42, + title: "Vanuatu", + childIds: [], + }, + 43: { + id: 43, + title: "Moon", + childIds: [44, 45, 46], + }, + 44: { + id: 44, + title: "Rheita", + childIds: [], + }, + 45: { + id: 45, + title: "Piccolomini", + childIds: [], + }, + 46: { + id: 46, + title: "Tycho", + childIds: [], + }, + 47: { + id: 47, + title: "Mars", + childIds: [48, 49], + }, + 48: { + id: 48, + title: "Corn Town", + childIds: [], + }, + 49: { + id: 49, + title: "Green Hill", + childIds: [], + }, +}; +``` + +```css +button { + margin: 10px; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +</DeepDive> + +Sometimes, you can also reduce state nesting by moving some of the nested state into the child components. This works well for ephemeral UI state that doesn't need to be stored, like whether an item is hovered. + +<Recap> + +- If two state variables always update together, consider merging them into one. +- Choose your state variables carefully to avoid creating "impossible" states. +- Structure your state in a way that reduces the chances that you'll make a mistake updating it. +- Avoid redundant and duplicate state so that you don't need to keep it in sync. +- Don't put props _into_ state unless you specifically want to prevent updates. +- For UI patterns like selection, keep ID or index in state instead of the object itself. +- If updating deeply nested state is complicated, try flattening it. + +</Recap> + +<Challenges> + +#### Fix a component that's not updating + +This `Clock` component receives two props: `color` and `time`. When you select a different color in the select box, the `Clock` component receives a different `color` prop from its parent component. However, for some reason, the displayed color doesn't update. Why? Fix the problem. + +```js +import { useState } from "react"; + +export default function Clock(props) { + const [color, setColor] = useState(props.color); + return <h1 style={{ color: color }}>{props.time}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + const [color, setColor] = useState("lightcoral"); + return ( + <div> + <p> + Pick a color:{" "} + <select + value={color} + onChange={(e) => setColor(e.target.value)} + > + <option value="lightcoral">lightcoral</option> + <option value="midnightblue">midnightblue</option> + <option value="rebeccapurple">rebeccapurple</option> + </select> + </p> + <Clock color={color} time={time.toLocaleTimeString()} /> + </div> + ); +} +``` + +<Solution> + +The issue is that this component has `color` state initialized with the initial value of the `color` prop. But when the `color` prop changes, this does not affect the state variable! So they get out of sync. To fix this issue, remove the state variable altogether, and use the `color` prop directly. + +```js +import { useState } from "react"; + +export default function Clock(props) { + return <h1 style={{ color: props.color }}>{props.time}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + const [color, setColor] = useState("lightcoral"); + return ( + <div> + <p> + Pick a color:{" "} + <select + value={color} + onChange={(e) => setColor(e.target.value)} + > + <option value="lightcoral">lightcoral</option> + <option value="midnightblue">midnightblue</option> + <option value="rebeccapurple">rebeccapurple</option> + </select> + </p> + <Clock color={color} time={time.toLocaleTimeString()} /> + </div> + ); +} +``` + +Or, using the destructuring syntax: + +```js +import { useState } from "react"; + +export default function Clock({ color, time }) { + return <h1 style={{ color: color }}>{time}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + const [color, setColor] = useState("lightcoral"); + return ( + <div> + <p> + Pick a color:{" "} + <select + value={color} + onChange={(e) => setColor(e.target.value)} + > + <option value="lightcoral">lightcoral</option> + <option value="midnightblue">midnightblue</option> + <option value="rebeccapurple">rebeccapurple</option> + </select> + </p> + <Clock color={color} time={time.toLocaleTimeString()} /> + </div> + ); +} +``` + +</Solution> + +#### Fix a broken packing list + +This packing list has a footer that shows how many items are packed, and how many items there are overall. It seems to work at first, but it is buggy. For example, if you mark an item as packed and then delete it, the counter will not be updated correctly. Fix the counter so that it's always correct. + +<Hint> + +Is any state in this example redundant? + +</Hint> + +```js +import { useState } from "react"; +import AddItem from "./AddItem.js"; +import PackingList from "./PackingList.js"; + +let nextId = 3; +const initialItems = [ + { id: 0, title: "Warm socks", packed: true }, + { id: 1, title: "Travel journal", packed: false }, + { id: 2, title: "Watercolors", packed: false }, +]; + +export default function TravelPlan() { + const [items, setItems] = useState(initialItems); + const [total, setTotal] = useState(3); + const [packed, setPacked] = useState(1); + + function handleAddItem(title) { + setTotal(total + 1); + setItems([ + ...items, + { + id: nextId++, + title: title, + packed: false, + }, + ]); + } + + function handleChangeItem(nextItem) { + if (nextItem.packed) { + setPacked(packed + 1); + } else { + setPacked(packed - 1); + } + setItems( + items.map((item) => { + if (item.id === nextItem.id) { + return nextItem; + } else { + return item; + } + }) + ); + } + + function handleDeleteItem(itemId) { + setTotal(total - 1); + setItems(items.filter((item) => item.id !== itemId)); + } + + return ( + <> + <AddItem onAddItem={handleAddItem} /> + <PackingList + items={items} + onChangeItem={handleChangeItem} + onDeleteItem={handleDeleteItem} + /> + <hr /> + <b> + {packed} out of {total} packed! + </b> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddItem({ onAddItem }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add item" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddItem(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function PackingList({ items, onChangeItem, onDeleteItem }) { + return ( + <ul> + {items.map((item) => ( + <li key={item.id}> + <label> + <input + type="checkbox" + checked={item.packed} + onChange={(e) => { + onChangeItem({ + ...item, + packed: e.target.checked, + }); + }} + />{" "} + {item.title} + </label> + <button on_click={() => onDeleteItem(item.id)}> + Delete + </button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +<Solution> + +Although you could carefully change each event handler to update the `total` and `packed` counters correctly, the root problem is that these state variables exist at all. They are redundant because you can always calculate the number of items (packed or total) from the `items` array itself. Remove the redundant state to fix the bug: + +```js +import { useState } from "react"; +import AddItem from "./AddItem.js"; +import PackingList from "./PackingList.js"; + +let nextId = 3; +const initialItems = [ + { id: 0, title: "Warm socks", packed: true }, + { id: 1, title: "Travel journal", packed: false }, + { id: 2, title: "Watercolors", packed: false }, +]; + +export default function TravelPlan() { + const [items, setItems] = useState(initialItems); + + const total = items.length; + const packed = items.filter((item) => item.packed).length; + + function handleAddItem(title) { + setItems([ + ...items, + { + id: nextId++, + title: title, + packed: false, + }, + ]); + } + + function handleChangeItem(nextItem) { + setItems( + items.map((item) => { + if (item.id === nextItem.id) { + return nextItem; + } else { + return item; + } + }) + ); + } + + function handleDeleteItem(itemId) { + setItems(items.filter((item) => item.id !== itemId)); + } + + return ( + <> + <AddItem onAddItem={handleAddItem} /> + <PackingList + items={items} + onChangeItem={handleChangeItem} + onDeleteItem={handleDeleteItem} + /> + <hr /> + <b> + {packed} out of {total} packed! + </b> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddItem({ onAddItem }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add item" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddItem(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function PackingList({ items, onChangeItem, onDeleteItem }) { + return ( + <ul> + {items.map((item) => ( + <li key={item.id}> + <label> + <input + type="checkbox" + checked={item.packed} + onChange={(e) => { + onChangeItem({ + ...item, + packed: e.target.checked, + }); + }} + />{" "} + {item.title} + </label> + <button on_click={() => onDeleteItem(item.id)}> + Delete + </button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +Notice how the event handlers are only concerned with calling `setItems` after this change. The item counts are now calculated during the next render from `items`, so they are always up-to-date. + +</Solution> + +#### Fix the disappearing selection + +There is a list of `letters` in state. When you hover or focus a particular letter, it gets highlighted. The currently highlighted letter is stored in the `highlightedLetter` state variable. You can "star" and "unstar" individual letters, which updates the `letters` array in state. + +This code works, but there is a minor UI glitch. When you press "Star" or "Unstar", the highlighting disappears for a moment. However, it reappears as soon as you move your pointer or switch to another letter with keyboard. Why is this happening? Fix it so that the highlighting doesn't disappear after the button click. + +```js +import { useState } from "react"; +import { initialLetters } from "./data.js"; +import Letter from "./Letter.js"; + +export default function MailClient() { + const [letters, setLetters] = useState(initialLetters); + const [highlightedLetter, setHighlightedLetter] = useState(null); + + function handleHover(letter) { + setHighlightedLetter(letter); + } + + function handleStar(starred) { + setLetters( + letters.map((letter) => { + if (letter.id === starred.id) { + return { + ...letter, + isStarred: !letter.isStarred, + }; + } else { + return letter; + } + }) + ); + } + + return ( + <> + <h2>Inbox</h2> + <ul> + {letters.map((letter) => ( + <Letter + key={letter.id} + letter={letter} + isHighlighted={letter === highlightedLetter} + onHover={handleHover} + onToggleStar={handleStar} + /> + ))} + </ul> + </> + ); +} +``` + +```js +export default function Letter({ + letter, + isHighlighted, + onHover, + onToggleStar, +}) { + return ( + <li + className={isHighlighted ? "highlighted" : ""} + onFocus={() => { + onHover(letter); + }} + onPointerMove={() => { + onHover(letter); + }} + > + <button + on_click={() => { + onToggleStar(letter); + }} + > + {letter.isStarred ? "Unstar" : "Star"} + </button> + {letter.subject} + </li> + ); +} +``` + +```js +export const initialLetters = [ + { + id: 0, + subject: "Ready for adventure?", + isStarred: true, + }, + { + id: 1, + subject: "Time to check in!", + isStarred: false, + }, + { + id: 2, + subject: "Festival Begins in Just SEVEN Days!", + isStarred: false, + }, +]; +``` + +```css +button { + margin: 5px; +} +li { + border-radius: 5px; +} +.highlighted { + background: #d2eaff; +} +``` + +<Solution> + +The problem is that you're holding the letter object in `highlightedLetter`. But you're also holding the same information in the `letters` array. So your state has duplication! When you update the `letters` array after the button click, you create a new letter object which is different from `highlightedLetter`. This is why `highlightedLetter === letter` check becomes `false`, and the highlight disappears. It reappears the next time you call `setHighlightedLetter` when the pointer moves. + +To fix the issue, remove the duplication from state. Instead of storing _the letter itself_ in two places, store the `highlightedId` instead. Then you can check `isHighlighted` for each letter with `letter.id === highlightedId`, which will work even if the `letter` object has changed since the last render. + +```js +import { useState } from "react"; +import { initialLetters } from "./data.js"; +import Letter from "./Letter.js"; + +export default function MailClient() { + const [letters, setLetters] = useState(initialLetters); + const [highlightedId, setHighlightedId] = useState(null); + + function handleHover(letterId) { + setHighlightedId(letterId); + } + + function handleStar(starredId) { + setLetters( + letters.map((letter) => { + if (letter.id === starredId) { + return { + ...letter, + isStarred: !letter.isStarred, + }; + } else { + return letter; + } + }) + ); + } + + return ( + <> + <h2>Inbox</h2> + <ul> + {letters.map((letter) => ( + <Letter + key={letter.id} + letter={letter} + isHighlighted={letter.id === highlightedId} + onHover={handleHover} + onToggleStar={handleStar} + /> + ))} + </ul> + </> + ); +} +``` + +```js +export default function Letter({ + letter, + isHighlighted, + onHover, + onToggleStar, +}) { + return ( + <li + className={isHighlighted ? "highlighted" : ""} + onFocus={() => { + onHover(letter.id); + }} + onPointerMove={() => { + onHover(letter.id); + }} + > + <button + on_click={() => { + onToggleStar(letter.id); + }} + > + {letter.isStarred ? "Unstar" : "Star"} + </button> + {letter.subject} + </li> + ); +} +``` + +```js +export const initialLetters = [ + { + id: 0, + subject: "Ready for adventure?", + isStarred: true, + }, + { + id: 1, + subject: "Time to check in!", + isStarred: false, + }, + { + id: 2, + subject: "Festival Begins in Just SEVEN Days!", + isStarred: false, + }, +]; +``` + +```css +button { + margin: 5px; +} +li { + border-radius: 5px; +} +.highlighted { + background: #d2eaff; +} +``` + +</Solution> + +#### Implement multiple selection + +In this example, each `Letter` has an `isSelected` prop and an `onToggle` handler that marks it as selected. This works, but the state is stored as a `selectedId` (either `null` or an ID), so only one letter can get selected at any given time. + +Change the state structure to support multiple selection. (How would you structure it? Think about this before writing the code.) Each checkbox should become independent from the others. Clicking a selected letter should uncheck it. Finally, the footer should show the correct number of the selected items. + +<Hint> + +Instead of a single selected ID, you might want to hold an array or a [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) of selected IDs in state. + +</Hint> + +```js +import { useState } from "react"; +import { letters } from "./data.js"; +import Letter from "./Letter.js"; + +export default function MailClient() { + const [selectedId, setSelectedId] = useState(null); + + // TODO: allow multiple selection + const selectedCount = 1; + + function handleToggle(toggledId) { + // TODO: allow multiple selection + setSelectedId(toggledId); + } + + return ( + <> + <h2>Inbox</h2> + <ul> + {letters.map((letter) => ( + <Letter + key={letter.id} + letter={letter} + isSelected={ + // TODO: allow multiple selection + letter.id === selectedId + } + onToggle={handleToggle} + /> + ))} + <hr /> + <p> + <b>You selected {selectedCount} letters</b> + </p> + </ul> + </> + ); +} +``` + +```js +export default function Letter({ letter, onToggle, isSelected }) { + return ( + <li className={isSelected ? "selected" : ""}> + <label> + <input + type="checkbox" + checked={isSelected} + onChange={() => { + onToggle(letter.id); + }} + /> + {letter.subject} + </label> + </li> + ); +} +``` + +```js +export const letters = [ + { + id: 0, + subject: "Ready for adventure?", + isStarred: true, + }, + { + id: 1, + subject: "Time to check in!", + isStarred: false, + }, + { + id: 2, + subject: "Festival Begins in Just SEVEN Days!", + isStarred: false, + }, +]; +``` + +```css +input { + margin: 5px; +} +li { + border-radius: 5px; +} +label { + width: 100%; + padding: 5px; + display: inline-block; +} +.selected { + background: #d2eaff; +} +``` + +<Solution> + +Instead of a single `selectedId`, keep a `selectedIds` _array_ in state. For example, if you select the first and the last letter, it would contain `[0, 2]`. When nothing is selected, it would be an empty `[]` array: + +```js +import { useState } from "react"; +import { letters } from "./data.js"; +import Letter from "./Letter.js"; + +export default function MailClient() { + const [selectedIds, setSelectedIds] = useState([]); + + const selectedCount = selectedIds.length; + + function handleToggle(toggledId) { + // Was it previously selected? + if (selectedIds.includes(toggledId)) { + // Then remove this ID from the array. + setSelectedIds(selectedIds.filter((id) => id !== toggledId)); + } else { + // Otherwise, add this ID to the array. + setSelectedIds([...selectedIds, toggledId]); + } + } + + return ( + <> + <h2>Inbox</h2> + <ul> + {letters.map((letter) => ( + <Letter + key={letter.id} + letter={letter} + isSelected={selectedIds.includes(letter.id)} + onToggle={handleToggle} + /> + ))} + <hr /> + <p> + <b>You selected {selectedCount} letters</b> + </p> + </ul> + </> + ); +} +``` + +```js +export default function Letter({ letter, onToggle, isSelected }) { + return ( + <li className={isSelected ? "selected" : ""}> + <label> + <input + type="checkbox" + checked={isSelected} + onChange={() => { + onToggle(letter.id); + }} + /> + {letter.subject} + </label> + </li> + ); +} +``` + +```js +export const letters = [ + { + id: 0, + subject: "Ready for adventure?", + isStarred: true, + }, + { + id: 1, + subject: "Time to check in!", + isStarred: false, + }, + { + id: 2, + subject: "Festival Begins in Just SEVEN Days!", + isStarred: false, + }, +]; +``` + +```css +input { + margin: 5px; +} +li { + border-radius: 5px; +} +label { + width: 100%; + padding: 5px; + display: inline-block; +} +.selected { + background: #d2eaff; +} +``` + +One minor downside of using an array is that for each item, you're calling `selectedIds.includes(letter.id)` to check whether it's selected. If the array is very large, this can become a performance problem because array search with [`includes()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes) takes linear time, and you're doing this search for each individual item. + +To fix this, you can hold a [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) in state instead, which provides a fast [`has()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/has) operation: + +```js +import { useState } from "react"; +import { letters } from "./data.js"; +import Letter from "./Letter.js"; + +export default function MailClient() { + const [selectedIds, setSelectedIds] = useState(new Set()); + + const selectedCount = selectedIds.size; + + function handleToggle(toggledId) { + // Create a copy (to avoid mutation). + const nextIds = new Set(selectedIds); + if (nextIds.has(toggledId)) { + nextIds.delete(toggledId); + } else { + nextIds.add(toggledId); + } + setSelectedIds(nextIds); + } + + return ( + <> + <h2>Inbox</h2> + <ul> + {letters.map((letter) => ( + <Letter + key={letter.id} + letter={letter} + isSelected={selectedIds.has(letter.id)} + onToggle={handleToggle} + /> + ))} + <hr /> + <p> + <b>You selected {selectedCount} letters</b> + </p> + </ul> + </> + ); +} +``` + +```js +export default function Letter({ letter, onToggle, isSelected }) { + return ( + <li className={isSelected ? "selected" : ""}> + <label> + <input + type="checkbox" + checked={isSelected} + onChange={() => { + onToggle(letter.id); + }} + /> + {letter.subject} + </label> + </li> + ); +} +``` + +```js +export const letters = [ + { + id: 0, + subject: "Ready for adventure?", + isStarred: true, + }, + { + id: 1, + subject: "Time to check in!", + isStarred: false, + }, + { + id: 2, + subject: "Festival Begins in Just SEVEN Days!", + isStarred: false, + }, +]; +``` + +```css +input { + margin: 5px; +} +li { + border-radius: 5px; +} +label { + width: 100%; + padding: 5px; + display: inline-block; +} +.selected { + background: #d2eaff; +} +``` + +Now each item does a `selectedIds.has(letter.id)` check, which is very fast. + +Keep in mind that you [should not mutate objects in state](/learn/updating-objects-in-state), and that includes Sets, too. This is why the `handleToggle` function creates a _copy_ of the Set first, and then updates that copy. + +</Solution> + +</Challenges> diff --git a/src/py/reactpy/tests/__init__.py b/docs/src/learn/communicate-data-between-server-and-client.md similarity index 100% rename from src/py/reactpy/tests/__init__.py rename to docs/src/learn/communicate-data-between-server-and-client.md diff --git a/docs/src/learn/conditional-rendering.md b/docs/src/learn/conditional-rendering.md new file mode 100644 index 000000000..47ac5b3ef --- /dev/null +++ b/docs/src/learn/conditional-rendering.md @@ -0,0 +1,572 @@ +## Overview + +<p class="intro" markdown> + +Your components will often need to display different things depending on different conditions. In React, you can conditionally render JSX using JavaScript syntax like `if` statements, `&&`, and `? :` operators. + +</p> + +!!! summary "You will learn" + + - How to return different JSX depending on a condition + - How to conditionally include or exclude a piece of JSX + - Common conditional syntax shortcuts you’ll encounter in React codebases + +## Conditionally returning JSX + +Let’s say you have a `PackingList` component rendering several `Item`s, which can be marked as packed or not: + +```js +function Item({ name, isPacked }) { + return <li className="item">{name}</li>; +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +Notice that some of the `Item` components have their `isPacked` prop set to `true` instead of `false`. You want to add a checkmark (✔) to packed items if `isPacked={true}`. + +You can write this as an [`if`/`else` statement](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/if...else) like so: + +```js +if (isPacked) { + return <li className="item">{name} ✔</li>; +} +return <li className="item">{name}</li>; +``` + +If the `isPacked` prop is `true`, this code **returns a different JSX tree.** With this change, some of the items get a checkmark at the end: + +```js +function Item({ name, isPacked }) { + if (isPacked) { + return <li className="item">{name} ✔</li>; + } + return <li className="item">{name}</li>; +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +Try editing what gets returned in either case, and see how the result changes! + +Notice how you're creating branching logic with JavaScript's `if` and `return` statements. In React, control flow (like conditions) is handled by JavaScript. + +### Conditionally returning nothing with `null` + +In some situations, you won't want to render anything at all. For example, say you don't want to show packed items at all. A component must return something. In this case, you can return `null`: + +```js +if (isPacked) { + return null; +} +return <li className="item">{name}</li>; +``` + +If `isPacked` is true, the component will return nothing, `null`. Otherwise, it will return JSX to render. + +```js +function Item({ name, isPacked }) { + if (isPacked) { + return null; + } + return <li className="item">{name}</li>; +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +In practice, returning `null` from a component isn't common because it might surprise a developer trying to render it. More often, you would conditionally include or exclude the component in the parent component's JSX. Here's how to do that! + +## Conditionally including JSX + +In the previous example, you controlled which (if any!) JSX tree would be returned by the component. You may already have noticed some duplication in the render output: + +```js +<li className="item">{name} ✔</li> +``` + +is very similar to + +```js +<li className="item">{name}</li> +``` + +Both of the conditional branches return `<li className="item">...</li>`: + +```js +if (isPacked) { + return <li className="item">{name} ✔</li>; +} +return <li className="item">{name}</li>; +``` + +While this duplication isn't harmful, it could make your code harder to maintain. What if you want to change the `className`? You'd have to do it in two places in your code! In such a situation, you could conditionally include a little JSX to make your code more [DRY.](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) + +### Conditional (ternary) operator (`? :`) + +JavaScript has a compact syntax for writing a conditional expression -- the [conditional operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) or "ternary operator". + +Instead of this: + +```js +if (isPacked) { + return <li className="item">{name} ✔</li>; +} +return <li className="item">{name}</li>; +``` + +You can write this: + +```js +return <li className="item">{isPacked ? name + " ✔" : name}</li>; +``` + +You can read it as _"if `isPacked` is true, then (`?`) render `name + ' ✔'`, otherwise (`:`) render `name`"_. + +<DeepDive> + +#### Are these two examples fully equivalent? + +If you're coming from an object-oriented programming background, you might assume that the two examples above are subtly different because one of them may create two different "instances" of `<li>`. But JSX elements aren't "instances" because they don't hold any internal state and aren't real DOM nodes. They're lightweight descriptions, like blueprints. So these two examples, in fact, _are_ completely equivalent. [Preserving and Resetting State](/learn/preserving-and-resetting-state) goes into detail about how this works. + +</DeepDive> + +Now let's say you want to wrap the completed item's text into another HTML tag, like `<del>` to strike it out. You can add even more newlines and parentheses so that it's easier to nest more JSX in each of the cases: + +```js +function Item({ name, isPacked }) { + return ( + <li className="item">{isPacked ? <del>{name + " ✔"}</del> : name}</li> + ); +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +This style works well for simple conditions, but use it in moderation. If your components get messy with too much nested conditional markup, consider extracting child components to clean things up. In React, markup is a part of your code, so you can use tools like variables and functions to tidy up complex expressions. + +### Logical AND operator (`&&`) + +Another common shortcut you'll encounter is the [JavaScript logical AND (`&&`) operator.](<https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND#:~:text=The%20logical%20AND%20(%20%26%26%20)%20operator,it%20returns%20a%20Boolean%20value.>) Inside React components, it often comes up when you want to render some JSX when the condition is true, **or render nothing otherwise.** With `&&`, you could conditionally render the checkmark only if `isPacked` is `true`: + +```js +return ( + <li className="item"> + {name} {isPacked && "✔"} + </li> +); +``` + +You can read this as _"if `isPacked`, then (`&&`) render the checkmark, otherwise, render nothing"_. + +Here it is in action: + +```js +function Item({ name, isPacked }) { + return ( + <li className="item"> + {name} {isPacked && "✔"} + </li> + ); +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +A [JavaScript && expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND) returns the value of its right side (in our case, the checkmark) if the left side (our condition) is `true`. But if the condition is `false`, the whole expression becomes `false`. React considers `false` as a "hole" in the JSX tree, just like `null` or `undefined`, and doesn't render anything in its place. + +<Pitfall> + +**Don't put numbers on the left side of `&&`.** + +To test the condition, JavaScript converts the left side to a boolean automatically. However, if the left side is `0`, then the whole expression gets that value (`0`), and React will happily render `0` rather than nothing. + +For example, a common mistake is to write code like `messageCount && <p>New messages</p>`. It's easy to assume that it renders nothing when `messageCount` is `0`, but it really renders the `0` itself! + +To fix it, make the left side a boolean: `messageCount > 0 && <p>New messages</p>`. + +</Pitfall> + +### Conditionally assigning JSX to a variable + +When the shortcuts get in the way of writing plain code, try using an `if` statement and a variable. You can reassign variables defined with [`let`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let), so start by providing the default content you want to display, the name: + +```js +let itemContent = name; +``` + +Use an `if` statement to reassign a JSX expression to `itemContent` if `isPacked` is `true`: + +```js +if (isPacked) { + itemContent = name + " ✔"; +} +``` + +[Curly braces open the "window into JavaScript".](/learn/javascript-in-jsx-with-curly-braces#using-curly-braces-a-window-into-the-javascript-world) Embed the variable with curly braces in the returned JSX tree, nesting the previously calculated expression inside of JSX: + +```js +<li className="item">{itemContent}</li> +``` + +This style is the most verbose, but it's also the most flexible. Here it is in action: + +```js +function Item({ name, isPacked }) { + let itemContent = name; + if (isPacked) { + itemContent = name + " ✔"; + } + return <li className="item">{itemContent}</li>; +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +Like before, this works not only for text, but for arbitrary JSX too: + +```js +function Item({ name, isPacked }) { + let itemContent = name; + if (isPacked) { + itemContent = <del>{name + " ✔"}</del>; + } + return <li className="item">{itemContent}</li>; +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +If you're not familiar with JavaScript, this variety of styles might seem overwhelming at first. However, learning them will help you read and write any JavaScript code -- and not just React components! Pick the one you prefer for a start, and then consult this reference again if you forget how the other ones work. + +<Recap> + +- In React, you control branching logic with JavaScript. +- You can return a JSX expression conditionally with an `if` statement. +- You can conditionally save some JSX to a variable and then include it inside other JSX by using the curly braces. +- In JSX, `{cond ? <A /> : <B />}` means _"if `cond`, render `<A />`, otherwise `<B />`"_. +- In JSX, `{cond && <A />}` means _"if `cond`, render `<A />`, otherwise nothing"_. +- The shortcuts are common, but you don't have to use them if you prefer plain `if`. + +</Recap> + +<Challenges> + +#### Show an icon for incomplete items with `? :` + +Use the conditional operator (`cond ? a : b`) to render a ❌ if `isPacked` isn’t `true`. + +```js +function Item({ name, isPacked }) { + return ( + <li className="item"> + {name} {isPacked && "✔"} + </li> + ); +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +<Solution> + +```js +function Item({ name, isPacked }) { + return ( + <li className="item"> + {name} {isPacked ? "✔" : "❌"} + </li> + ); +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item isPacked={true} name="Space suit" /> + <Item isPacked={true} name="Helmet with a golden leaf" /> + <Item isPacked={false} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +</Solution> + +#### Show the item importance with `&&` + +In this example, each `Item` receives a numerical `importance` prop. Use the `&&` operator to render "_(Importance: X)_" in italics, but only for items that have non-zero importance. Your item list should end up looking like this: + +- Space suit _(Importance: 9)_ +- Helmet with a golden leaf +- Photo of Tam _(Importance: 6)_ + +Don't forget to add a space between the two labels! + +```js +function Item({ name, importance }) { + return <li className="item">{name}</li>; +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item importance={9} name="Space suit" /> + <Item importance={0} name="Helmet with a golden leaf" /> + <Item importance={6} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +<Solution> + +This should do the trick: + +```js +function Item({ name, importance }) { + return ( + <li className="item"> + {name} + {importance > 0 && " "} + {importance > 0 && <i>(Importance: {importance})</i>} + </li> + ); +} + +export default function PackingList() { + return ( + <section> + <h1>Sally Ride's Packing List</h1> + <ul> + <Item importance={9} name="Space suit" /> + <Item importance={0} name="Helmet with a golden leaf" /> + <Item importance={6} name="Photo of Tam" /> + </ul> + </section> + ); +} +``` + +Note that you must write `importance > 0 && ...` rather than `importance && ...` so that if the `importance` is `0`, `0` isn't rendered as the result! + +In this solution, two separate conditions are used to insert a space between the name and the importance label. Alternatively, you could use a fragment with a leading space: `importance > 0 && <> <i>...</i></>` or add a space immediately inside the `<i>`: `importance > 0 && <i> ...</i>`. + +</Solution> + +#### Refactor a series of `? :` to `if` and variables + +This `Drink` component uses a series of `? :` conditions to show different information depending on whether the `name` prop is `"tea"` or `"coffee"`. The problem is that the information about each drink is spread across multiple conditions. Refactor this code to use a single `if` statement instead of three `? :` conditions. + +```js +function Drink({ name }) { + return ( + <section> + <h1>{name}</h1> + <dl> + <dt>Part of plant</dt> + <dd>{name === "tea" ? "leaf" : "bean"}</dd> + <dt>Caffeine content</dt> + <dd>{name === "tea" ? "15–70 mg/cup" : "80–185 mg/cup"}</dd> + <dt>Age</dt> + <dd>{name === "tea" ? "4,000+ years" : "1,000+ years"}</dd> + </dl> + </section> + ); +} + +export default function DrinkList() { + return ( + <div> + <Drink name="tea" /> + <Drink name="coffee" /> + </div> + ); +} +``` + +Once you've refactored the code to use `if`, do you have further ideas on how to simplify it? + +<Solution> + +There are multiple ways you could go about this, but here is one starting point: + +```js +function Drink({ name }) { + let part, caffeine, age; + if (name === "tea") { + part = "leaf"; + caffeine = "15–70 mg/cup"; + age = "4,000+ years"; + } else if (name === "coffee") { + part = "bean"; + caffeine = "80–185 mg/cup"; + age = "1,000+ years"; + } + return ( + <section> + <h1>{name}</h1> + <dl> + <dt>Part of plant</dt> + <dd>{part}</dd> + <dt>Caffeine content</dt> + <dd>{caffeine}</dd> + <dt>Age</dt> + <dd>{age}</dd> + </dl> + </section> + ); +} + +export default function DrinkList() { + return ( + <div> + <Drink name="tea" /> + <Drink name="coffee" /> + </div> + ); +} +``` + +Here the information about each drink is grouped together instead of being spread across multiple conditions. This makes it easier to add more drinks in the future. + +Another solution would be to remove the condition altogether by moving the information into objects: + +```js +const drinks = { + tea: { + part: "leaf", + caffeine: "15–70 mg/cup", + age: "4,000+ years", + }, + coffee: { + part: "bean", + caffeine: "80–185 mg/cup", + age: "1,000+ years", + }, +}; + +function Drink({ name }) { + const info = drinks[name]; + return ( + <section> + <h1>{name}</h1> + <dl> + <dt>Part of plant</dt> + <dd>{info.part}</dd> + <dt>Caffeine content</dt> + <dd>{info.caffeine}</dd> + <dt>Age</dt> + <dd>{info.age}</dd> + </dl> + </section> + ); +} + +export default function DrinkList() { + return ( + <div> + <Drink name="tea" /> + <Drink name="coffee" /> + </div> + ); +} +``` + +</Solution> + +</Challenges> diff --git a/src/py/reactpy/tests/test__console/__init__.py b/docs/src/learn/convert-between-vdom-and-html.md similarity index 100% rename from src/py/reactpy/tests/test__console/__init__.py rename to docs/src/learn/convert-between-vdom-and-html.md diff --git a/docs/src/learn/creating-a-react-app.md b/docs/src/learn/creating-a-react-app.md new file mode 100644 index 000000000..5669d055c --- /dev/null +++ b/docs/src/learn/creating-a-react-app.md @@ -0,0 +1,95 @@ +## Overview + +<p class="intro" markdown> + +If you want to build a new app or website with React, we recommend starting with a standalone executor. + +</p> + +If your app has constraints not well-served by existing web frameworks, you prefer to build your own framework, or you just want to learn the basics of a React app, you can use ReactPy in **standalone mode**. + +## Using ReactPy for full-stack + +ReactPy is a component library that helps you build a full-stack web application. For convenience, ReactPy is also bundled with several different standalone executors. + +These standalone executors are the easiest way to get started with ReactPy, as they require no additional setup or configuration. + +!!! abstract "Note" + + **Standalone ReactPy requires a server** + + In order to serve the initial HTML page, you will need to run a server. The ASGI examples below use [Uvicorn](https://www.uvicorn.org/), but you can use [any ASGI server](https://github.com/florimondmanca/awesome-asgi#servers). + + Executors on this page can either support client-side rendering ([CSR](https://developer.mozilla.org/en-US/docs/Glossary/CSR)) or server-side rendering ([SSR](https://developer.mozilla.org/en-US/docs/Glossary/SSR)) + +### Running via ASGI SSR + +ReactPy can run in **server-side standalone mode**, where both page loading and component rendering occurs on an ASGI server. + +This executor is the most commonly used, as it provides maximum extensibility. + +First, install ReactPy and your preferred ASGI webserver. + +!!! example "Terminal" + + ```linenums="0" + pip install reactpy[asgi] uvicorn[standard] + ``` + +Next, create a new file called `main.py` containing the ASGI application: + +=== "main.py" + + ```python + {% include "../../examples/creating_a_react_app/asgi_ssr.py" %} + ``` + +Finally, use your webserver of choice to start ReactPy: + +!!! example "Terminal" + + ```linenums="0" + uvicorn main:my_app + ``` + +### Running via ASGI CSR + +ReactPy can run in **client-side standalone mode**, where the initial page is served using the ASGI protocol. This is configuration allows direct execution of Javascript, but requires special considerations since all ReactPy component code is run on the browser [via WebAssembly](https://pyscript.net/). + +First, install ReactPy and your preferred ASGI webserver. + +!!! example "Terminal" + + ```linenums="0" + pip install reactpy[asgi] uvicorn[standard] + ``` + +Next, create a new file called `main.py` containing the ASGI application, and a `root.py` file containing the root component: + +=== "main.py" + + ```python + {% include "../../examples/creating_a_react_app/asgi_csr.py" %} + ``` + +=== "root.py" + + ```python + {% include "../../examples/creating_a_react_app/asgi_csr_root.py" %} + ``` + +Finally, use your webserver of choice to start ReactPy: + +!!! example "Terminal" + + ```linenums="0" + uvicorn main:my_app + ``` + +### Running via WSGI SSR + +Support for WSGI executors is coming in a [future version](https://github.com/reactive-python/reactpy/issues/1260). + +### Running via WSGI CSR + +Support for WSGI executors is coming in a [future version](https://github.com/reactive-python/reactpy/issues/1260). diff --git a/src/py/reactpy/tests/test_backend/__init__.py b/docs/src/learn/creating-backends.md similarity index 100% rename from src/py/reactpy/tests/test_backend/__init__.py rename to docs/src/learn/creating-backends.md diff --git a/src/py/reactpy/tests/test_core/__init__.py b/docs/src/learn/creating-html-tags.md similarity index 100% rename from src/py/reactpy/tests/test_core/__init__.py rename to docs/src/learn/creating-html-tags.md diff --git a/src/py/reactpy/tests/test_web/__init__.py b/docs/src/learn/creating-vdom-event-handlers.md similarity index 100% rename from src/py/reactpy/tests/test_web/__init__.py rename to docs/src/learn/creating-vdom-event-handlers.md diff --git a/docs/src/learn/editor-setup.md b/docs/src/learn/editor-setup.md new file mode 100644 index 000000000..052f16663 --- /dev/null +++ b/docs/src/learn/editor-setup.md @@ -0,0 +1,70 @@ +## Overview + +<p class="intro" markdown> + +A properly configured editor can make code clearer to read and faster to write. It can even help you catch bugs as you write them! If this is your first time setting up an editor or you're looking to tune up your current editor, we have a few recommendations. + +</p> + +!!! summary "You will learn" + + - What the most popular editors are + - How to format your code automatically + +## Your editor + +[VS Code](https://code.visualstudio.com/) is one of the most popular editors in use today. It has a large marketplace of extensions and integrates well with popular services like GitHub. Most of the features listed below can be added to VS Code as extensions as well, making it highly configurable! + +Other popular text editors used in the React community include: + +- [WebStorm](https://www.jetbrains.com/webstorm/) is an integrated development environment designed specifically for JavaScript. +- [Sublime Text](https://www.sublimetext.com/) has support for [syntax highlighting](https://stackoverflow.com/a/70960574/458193) and autocomplete built in. +- [Vim](https://www.vim.org/) is a highly configurable text editor built to make creating and changing any kind of text very efficient. It is included as "vi" with most UNIX systems and with Apple OS X. + +## Recommended text editor features + +Some editors come with these features built in, but others might require adding an extension. Check to see what support your editor of choice provides to be sure! + +### Python Linting + +Linting is the process of running a program that will analyse code for potential errors. [Flake8](https://flake8.pycqa.org/en/latest/) is a popular, open source linter for Python. + +- [Install Flake8](https://flake8.pycqa.org/en/latest/#installation) (be sure you have [Python installed!](https://www.python.org/downloads/)) +- [Integrate Flake8 in VSCode with the official extension](https://marketplace.visualstudio.com/items?itemName=ms-python.flake8) +- [Install Reactpy-Flake8](https://pypi.org/project/reactpy-flake8/) to lint your ReactPy code + +### JavaScript Linting + +You typically won't use much JavaScript alongside ReactPy, but there are still some cases where you might. For example, you might want to use JavaScript to fetch data from an API or to add some interactivity to your app. + +In these cases, it's helpful to have a linter that can catch common mistakes in your code as you write it. [ESLint](https://eslint.org/) is a popular, open source linter for JavaScript. + +- [Install ESLint with the recommended configuration for React](https://www.npmjs.com/package/eslint-config-react-app) (be sure you have [Node installed!](https://nodejs.org/en/download/current/)) +- [Integrate ESLint in VSCode with the official extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + +**Make sure that you've enabled all the [`eslint-plugin-react-hooks`](https://www.npmjs.com/package/eslint-plugin-react-hooks) rules for your project.** They are essential and catch the most severe bugs early. The recommended [`eslint-config-react-app`](https://www.npmjs.com/package/eslint-config-react-app) preset already includes them. + +### Formatting + +The last thing you want to do when sharing your code with another contributor is get into an discussion about [tabs vs spaces](https://www.google.com/search?q=tabs+vs+spaces)! Fortunately, [Prettier](https://prettier.io/) will clean up your code by reformatting it to conform to preset, configurable rules. Run Prettier, and all your tabs will be converted to spaces—and your indentation, quotes, etc will also all be changed to conform to the configuration. In the ideal setup, Prettier will run when you save your file, quickly making these edits for you. + +You can install the [Prettier extension in VSCode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) by following these steps: + +1. Launch VS Code +2. Use Quick Open, press ++ctrl+p++ +3. Paste in `ext install esbenp.prettier-vscode` +4. Press Enter + +#### Formatting on save + +Ideally, you should format your code on every save. VS Code has settings for this! + +1. In VS Code, press ++ctrl+shift+p++ +2. Type "settings" +3. Hit Enter +4. In the search bar, type "format on save" +5. Be sure the "format on save" option is ticked! + +!!! abstract "Note" + + If your ESLint preset has formatting rules, they may conflict with Prettier. We recommend disabling all formatting rules in your ESLint preset using [`eslint-config-prettier`](https://github.com/prettier/eslint-config-prettier) so that ESLint is _only_ used for catching logical mistakes. If you want to enforce that files are formatted before a pull request is merged, use [`prettier --check`](https://prettier.io/docs/en/cli.html#--check) for your continuous integration. diff --git a/docs/src/learn/extra-tools-and-packages.md b/docs/src/learn/extra-tools-and-packages.md new file mode 100644 index 000000000..17a619944 --- /dev/null +++ b/docs/src/learn/extra-tools-and-packages.md @@ -0,0 +1,2 @@ +- ReactPy Router +- ReactPy Flake8 diff --git a/docs/src/learn/extracting-state-logic-into-a-reducer.md b/docs/src/learn/extracting-state-logic-into-a-reducer.md new file mode 100644 index 000000000..bf871ac88 --- /dev/null +++ b/docs/src/learn/extracting-state-logic-into-a-reducer.md @@ -0,0 +1,2640 @@ +## Overview + +<p class="intro" markdown> + +Components with many state updates spread across many event handlers can get overwhelming. For these cases, you can consolidate all the state update logic outside your component in a single function, called a _reducer._ + +</p> + +!!! summary "You will learn" + + - What a reducer function is + - How to refactor `useState` to `useReducer` + - When to use a reducer + - How to write one well + +## Consolidate state logic with a reducer + +As your components grow in complexity, it can get harder to see at a glance all the different ways in which a component's state gets updated. For example, the `TaskApp` component below holds an array of `tasks` in state and uses three different event handlers to add, remove, and edit tasks: + +```js +import { useState } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +export default function TaskApp() { + const [tasks, setTasks] = useState(initialTasks); + + function handleAddTask(text) { + setTasks([ + ...tasks, + { + id: nextId++, + text: text, + done: false, + }, + ]); + } + + function handleChangeTask(task) { + setTasks( + tasks.map((t) => { + if (t.id === task.id) { + return task; + } else { + return t; + } + }) + ); + } + + function handleDeleteTask(taskId) { + setTasks(tasks.filter((t) => t.id !== taskId)); + } + + return ( + <> + <h1>Prague itinerary</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Visit Kafka Museum", done: true }, + { id: 1, text: "Watch a puppet show", done: false }, + { id: 2, text: "Lennon Wall pic", done: false }, +]; +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +Each of its event handlers calls `setTasks` in order to update the state. As this component grows, so does the amount of state logic sprinkled throughout it. To reduce this complexity and keep all your logic in one easy-to-access place, you can move that state logic into a single function outside your component, **called a "reducer".** + +Reducers are a different way to handle state. You can migrate from `useState` to `useReducer` in three steps: + +1. **Move** from setting state to dispatching actions. +2. **Write** a reducer function. +3. **Use** the reducer from your component. + +### Step 1: Move from setting state to dispatching actions + +Your event handlers currently specify _what to do_ by setting state: + +```js +function handleAddTask(text) { + setTasks([ + ...tasks, + { + id: nextId++, + text: text, + done: false, + }, + ]); +} + +function handleChangeTask(task) { + setTasks( + tasks.map((t) => { + if (t.id === task.id) { + return task; + } else { + return t; + } + }) + ); +} + +function handleDeleteTask(taskId) { + setTasks(tasks.filter((t) => t.id !== taskId)); +} +``` + +Remove all the state setting logic. What you are left with are three event handlers: + +- `handleAddTask(text)` is called when the user presses "Add". +- `handleChangeTask(task)` is called when the user toggles a task or presses "Save". +- `handleDeleteTask(taskId)` is called when the user presses "Delete". + +Managing state with reducers is slightly different from directly setting state. Instead of telling React "what to do" by setting state, you specify "what the user just did" by dispatching "actions" from your event handlers. (The state update logic will live elsewhere!) So instead of "setting `tasks`" via an event handler, you're dispatching an "added/changed/deleted a task" action. This is more descriptive of the user's intent. + +```js +function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); +} + +function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); +} + +function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); +} +``` + +The object you pass to `dispatch` is called an "action": + +```js +function handleDeleteTask(taskId) { + dispatch( + // "action" object: + { + type: "deleted", + id: taskId, + } + ); +} +``` + +It is a regular JavaScript object. You decide what to put in it, but generally it should contain the minimal information about _what happened_. (You will add the `dispatch` function itself in a later step.) + +<Note> + +An action object can have any shape. + +By convention, it is common to give it a string `type` that describes what happened, and pass any additional information in other fields. The `type` is specific to a component, so in this example either `'added'` or `'added_task'` would be fine. Choose a name that says what happened! + +```js +dispatch({ + // specific to component + type: "what_happened", + // other fields go here +}); +``` + +</Note> + +### Step 2: Write a reducer function + +A reducer function is where you will put your state logic. It takes two arguments, the current state and the action object, and it returns the next state: + +```js +function yourReducer(state, action) { + // return next state for React to set +} +``` + +React will set the state to what you return from the reducer. + +To move your state setting logic from your event handlers to a reducer function in this example, you will: + +1. Declare the current state (`tasks`) as the first argument. +2. Declare the `action` object as the second argument. +3. Return the _next_ state from the reducer (which React will set the state to). + +Here is all the state setting logic migrated to a reducer function: + +```js +function tasksReducer(tasks, action) { + if (action.type === "added") { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } else if (action.type === "changed") { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } else if (action.type === "deleted") { + return tasks.filter((t) => t.id !== action.id); + } else { + throw Error("Unknown action: " + action.type); + } +} +``` + +Because the reducer function takes state (`tasks`) as an argument, you can **declare it outside of your component.** This decreases the indentation level and can make your code easier to read. + +<Note> + +The code above uses if/else statements, but it's a convention to use [switch statements](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/switch) inside reducers. The result is the same, but it can be easier to read switch statements at a glance. + +We'll be using them throughout the rest of this documentation like so: + +```js +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +We recommend wrapping each `case` block into the `{` and `}` curly braces so that variables declared inside of different `case`s don't clash with each other. Also, a `case` should usually end with a `return`. If you forget to `return`, the code will "fall through" to the next `case`, which can lead to mistakes! + +If you're not yet comfortable with switch statements, using if/else is completely fine. + +</Note> + +<DeepDive> + +#### Why are reducers called this way? + +Although reducers can "reduce" the amount of code inside your component, they are actually named after the [`reduce()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce) operation that you can perform on arrays. + +The `reduce()` operation lets you take an array and "accumulate" a single value out of many: + +``` +const arr = [1, 2, 3, 4, 5]; +const sum = arr.reduce( + (result, number) => result + number +); // 1 + 2 + 3 + 4 + 5 +``` + +The function you pass to `reduce` is known as a "reducer". It takes the _result so far_ and the _current item,_ then it returns the _next result._ React reducers are an example of the same idea: they take the _state so far_ and the _action_, and return the _next state._ In this way, they accumulate actions over time into state. + +You could even use the `reduce()` method with an `initialState` and an array of `actions` to calculate the final state by passing your reducer function to it: + +```js +import tasksReducer from "./tasksReducer.js"; + +let initialState = []; +let actions = [ + { type: "added", id: 1, text: "Visit Kafka Museum" }, + { type: "added", id: 2, text: "Watch a puppet show" }, + { type: "deleted", id: 1 }, + { type: "added", id: 3, text: "Lennon Wall pic" }, +]; + +let finalState = actions.reduce(tasksReducer, initialState); + +const output = document.getElementById("output"); +output.textContent = JSON.stringify(finalState, null, 2); +``` + +```js +export default function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```html +<pre id="output"></pre> +``` + +You probably won't need to do this yourself, but this is similar to what React does! + +</DeepDive> + +### Step 3: Use the reducer from your component + +Finally, you need to hook up the `tasksReducer` to your component. Import the `useReducer` Hook from React: + +```js +import { useReducer } from "react"; +``` + +Then you can replace `useState`: + +```js +const [tasks, setTasks] = useState(initialTasks); +``` + +with `useReducer` like so: + +```js +const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); +``` + +The `useReducer` Hook is similar to `useState`—you must pass it an initial state and it returns a stateful value and a way to set state (in this case, the dispatch function). But it's a little different. + +The `useReducer` Hook takes two arguments: + +1. A reducer function +2. An initial state + +And it returns: + +1. A stateful value +2. A dispatch function (to "dispatch" user actions to the reducer) + +Now it's fully wired up! Here, the reducer is declared at the bottom of the component file: + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Prague itinerary</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Visit Kafka Museum", done: true }, + { id: 1, text: "Watch a puppet show", done: false }, + { id: 2, text: "Lennon Wall pic", done: false }, +]; +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +If you want, you can even move the reducer to a different file: + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; +import tasksReducer from "./tasksReducer.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Prague itinerary</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Visit Kafka Museum", done: true }, + { id: 1, text: "Watch a puppet show", done: false }, + { id: 2, text: "Lennon Wall pic", done: false }, +]; +``` + +```js +export default function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +Component logic can be easier to read when you separate concerns like this. Now the event handlers only specify _what happened_ by dispatching actions, and the reducer function determines _how the state updates_ in response to them. + +## Comparing `useState` and `useReducer` + +Reducers are not without downsides! Here's a few ways you can compare them: + +- **Code size:** Generally, with `useState` you have to write less code upfront. With `useReducer`, you have to write both a reducer function _and_ dispatch actions. However, `useReducer` can help cut down on the code if many event handlers modify state in a similar way. +- **Readability:** `useState` is very easy to read when the state updates are simple. When they get more complex, they can bloat your component's code and make it difficult to scan. In this case, `useReducer` lets you cleanly separate the _how_ of update logic from the _what happened_ of event handlers. +- **Debugging:** When you have a bug with `useState`, it can be difficult to tell _where_ the state was set incorrectly, and _why_. With `useReducer`, you can add a console log into your reducer to see every state update, and _why_ it happened (due to which `action`). If each `action` is correct, you'll know that the mistake is in the reducer logic itself. However, you have to step through more code than with `useState`. +- **Testing:** A reducer is a pure function that doesn't depend on your component. This means that you can export and test it separately in isolation. While generally it's best to test components in a more realistic environment, for complex state update logic it can be useful to assert that your reducer returns a particular state for a particular initial state and action. +- **Personal preference:** Some people like reducers, others don't. That's okay. It's a matter of preference. You can always convert between `useState` and `useReducer` back and forth: they are equivalent! + +We recommend using a reducer if you often encounter bugs due to incorrect state updates in some component, and want to introduce more structure to its code. You don't have to use reducers for everything: feel free to mix and match! You can even `useState` and `useReducer` in the same component. + +## Writing reducers well + +Keep these two tips in mind when writing reducers: + +- **Reducers must be pure.** Similar to [state updater functions](/learn/queueing-a-series-of-state-updates), reducers run during rendering! (Actions are queued until the next render.) This means that reducers [must be pure](/learn/keeping-components-pure)—same inputs always result in the same output. They should not send requests, schedule timeouts, or perform any side effects (operations that impact things outside the component). They should update [objects](/learn/updating-objects-in-state) and [arrays](/learn/updating-arrays-in-state) without mutations. +- **Each action describes a single user interaction, even if that leads to multiple changes in the data.** For example, if a user presses "Reset" on a form with five fields managed by a reducer, it makes more sense to dispatch one `reset_form` action rather than five separate `set_field` actions. If you log every action in a reducer, that log should be clear enough for you to reconstruct what interactions or responses happened in what order. This helps with debugging! + +## Writing concise reducers with Immer + +Just like with [updating objects](/learn/updating-objects-in-state#write-concise-update-logic-with-immer) and [arrays](/learn/updating-arrays-in-state#write-concise-update-logic-with-immer) in regular state, you can use the Immer library to make reducers more concise. Here, [`useImmerReducer`](https://github.com/immerjs/use-immer#useimmerreducer) lets you mutate the state with `push` or `arr[i] =` assignment: + +```js +import { useImmerReducer } from "use-immer"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +function tasksReducer(draft, action) { + switch (action.type) { + case "added": { + draft.push({ + id: action.id, + text: action.text, + done: false, + }); + break; + } + case "changed": { + const index = draft.findIndex((t) => t.id === action.task.id); + draft[index] = action.task; + break; + } + case "deleted": { + return draft.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +export default function TaskApp() { + const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Prague itinerary</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Visit Kafka Museum", done: true }, + { id: 1, text: "Watch a puppet show", done: false }, + { id: 2, text: "Lennon Wall pic", done: false }, +]; +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +Reducers must be pure, so they shouldn't mutate state. But Immer provides you with a special `draft` object which is safe to mutate. Under the hood, Immer will create a copy of your state with the changes you made to the `draft`. This is why reducers managed by `useImmerReducer` can mutate their first argument and don't need to return state. + +<Recap> + +- To convert from `useState` to `useReducer`: + 1. Dispatch actions from event handlers. + 2. Write a reducer function that returns the next state for a given state and action. + 3. Replace `useState` with `useReducer`. +- Reducers require you to write a bit more code, but they help with debugging and testing. +- Reducers must be pure. +- Each action describes a single user interaction. +- Use Immer if you want to write reducers in a mutating style. + +</Recap> + +<Challenges> + +#### Dispatch actions from event handlers + +Currently, the event handlers in `ContactList.js` and `Chat.js` have `// TODO` comments. This is why typing into the input doesn't work, and clicking on the buttons doesn't change the selected recipient. + +Replace these two `// TODO`s with the code to `dispatch` the corresponding actions. To see the expected shape and the type of the actions, check the reducer in `messengerReducer.js`. The reducer is already written so you won't need to change it. You only need to dispatch the actions in `ContactList.js` and `Chat.js`. + +<Hint> + +The `dispatch` function is already available in both of these components because it was passed as a prop. So you need to call `dispatch` with the corresponding action object. + +To check the action object shape, you can look at the reducer and see which `action` fields it expects to see. For example, the `changed_selection` case in the reducer looks like this: + +```js +case 'changed_selection': { + return { + ...state, + selectedId: action.contactId + }; +} +``` + +This means that your action object should have a `type: 'changed_selection'`. You also see the `action.contactId` being used, so you need to include a `contactId` property into your action. + +</Hint> + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.message; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + message: "Hello", +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + message: "", + }; + } + case "edited_message": { + return { + ...state, + message: action.message, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + // TODO: dispatch changed_selection + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + // TODO: dispatch edited_message + // (Read the input value from e.target.value) + }} + /> + <br /> + <button>Send to {contact.email}</button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +<Solution> + +From the reducer code, you can infer that actions need to look like this: + +```js +// When the user presses "Alice" +dispatch({ + type: "changed_selection", + contactId: 1, +}); + +// When user types "Hello!" +dispatch({ + type: "edited_message", + message: "Hello!", +}); +``` + +Here is the example updated to dispatch the corresponding messages: + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.message; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + message: "Hello", +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + message: "", + }; + } + case "edited_message": { + return { + ...state, + message: action.message, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button>Send to {contact.email}</button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +</Solution> + +#### Clear the input on sending a message + +Currently, pressing "Send" doesn't do anything. Add an event handler to the "Send" button that will: + +1. Show an `alert` with the recipient's email and the message. +2. Clear the message input. + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.message; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + message: "Hello", +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + message: "", + }; + } + case "edited_message": { + return { + ...state, + message: action.message, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button>Send to {contact.email}</button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +<Solution> + +There are a couple of ways you could do it in the "Send" button event handler. One approach is to show an alert and then dispatch an `edited_message` action with an empty `message`: + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.message; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + message: "Hello", +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + message: "", + }; + } + case "edited_message": { + return { + ...state, + message: action.message, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button + on_click={() => { + alert(`Sending "${message}" to ${contact.email}`); + dispatch({ + type: "edited_message", + message: "", + }); + }} + > + Send to {contact.email} + </button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +This works and clears the input when you hit "Send". + +However, _from the user's perspective_, sending a message is a different action than editing the field. To reflect that, you could instead create a _new_ action called `sent_message`, and handle it separately in the reducer: + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.message; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + message: "Hello", +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + message: "", + }; + } + case "edited_message": { + return { + ...state, + message: action.message, + }; + } + case "sent_message": { + return { + ...state, + message: "", + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button + on_click={() => { + alert(`Sending "${message}" to ${contact.email}`); + dispatch({ + type: "sent_message", + }); + }} + > + Send to {contact.email} + </button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +The resulting behavior is the same. But keep in mind that action types should ideally describe "what the user did" rather than "how you want the state to change". This makes it easier to later add more features. + +With either solution, it's important that you **don't** place the `alert` inside a reducer. The reducer should be a pure function--it should only calculate the next state. It should not "do" anything, including displaying messages to the user. That should happen in the event handler. (To help catch mistakes like this, React will call your reducers multiple times in Strict Mode. This is why, if you put an alert in a reducer, it fires twice.) + +</Solution> + +#### Restore input values when switching between tabs + +In this example, switching between different recipients always clears the text input: + +```js +case 'changed_selection': { + return { + ...state, + selectedId: action.contactId, + message: '' // Clears the input + }; +``` + +This is because you don't want to share a single message draft between several recipients. But it would be better if your app "remembered" a draft for each contact separately, restoring them when you switch contacts. + +Your task is to change the way the state is structured so that you remember a separate message draft _per contact_. You would need to make a few changes to the reducer, the initial state, and the components. + +<Hint> + +You can structure your state like this: + +```js +export const initialState = { + selectedId: 0, + messages: { + 0: "Hello, Taylor", // Draft for contactId = 0 + 1: "Hello, Alice", // Draft for contactId = 1 + }, +}; +``` + +The `[key]: value` [computed property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#computed_property_names) syntax can help you update the `messages` object: + +```js +{ + ...state.messages, + [id]: message +} +``` + +</Hint> + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.message; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + message: "Hello", +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + message: "", + }; + } + case "edited_message": { + return { + ...state, + message: action.message, + }; + } + case "sent_message": { + return { + ...state, + message: "", + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button + on_click={() => { + alert(`Sending "${message}" to ${contact.email}`); + dispatch({ + type: "sent_message", + }); + }} + > + Send to {contact.email} + </button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +<Solution> + +You'll need to update the reducer to store and update a separate message draft per contact: + +```js +// When the input is edited +case 'edited_message': { + return { + // Keep other state like selection + ...state, + messages: { + // Keep messages for other contacts + ...state.messages, + // But change the selected contact's message + [state.selectedId]: action.message + } + }; +} +``` + +You would also update the `Messenger` component to read the message for the currently selected contact: + +```js +const message = state.messages[state.selectedId]; +``` + +Here is the complete solution: + +```js +import { useReducer } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.messages[state.selectedId]; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + messages: { + 0: "Hello, Taylor", + 1: "Hello, Alice", + 2: "Hello, Bob", + }, +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + }; + } + case "edited_message": { + return { + ...state, + messages: { + ...state.messages, + [state.selectedId]: action.message, + }, + }; + } + case "sent_message": { + return { + ...state, + messages: { + ...state.messages, + [state.selectedId]: "", + }, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button + on_click={() => { + alert(`Sending "${message}" to ${contact.email}`); + dispatch({ + type: "sent_message", + }); + }} + > + Send to {contact.email} + </button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +Notably, you didn't need to change any of the event handlers to implement this different behavior. Without a reducer, you would have to change every event handler that updates the state. + +</Solution> + +#### Implement `useReducer` from scratch + +In the earlier examples, you imported the `useReducer` Hook from React. This time, you will implement _the `useReducer` Hook itself!_ Here is a stub to get you started. It shouldn't take more than 10 lines of code. + +To test your changes, try typing into the input or select a contact. + +<Hint> + +Here is a more detailed sketch of the implementation: + +```js +export function useReducer(reducer, initialState) { + const [state, setState] = useState(initialState); + + function dispatch(action) { + // ??? + } + + return [state, dispatch]; +} +``` + +Recall that a reducer function takes two arguments--the current state and the action object--and it returns the next state. What should your `dispatch` implementation do with it? + +</Hint> + +```js +import { useReducer } from "./MyReact.js"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.messages[state.selectedId]; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + messages: { + 0: "Hello, Taylor", + 1: "Hello, Alice", + 2: "Hello, Bob", + }, +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + }; + } + case "edited_message": { + return { + ...state, + messages: { + ...state.messages, + [state.selectedId]: action.message, + }, + }; + } + case "sent_message": { + return { + ...state, + messages: { + ...state.messages, + [state.selectedId]: "", + }, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +import { useState } from "react"; + +export function useReducer(reducer, initialState) { + const [state, setState] = useState(initialState); + + // ??? + + return [state, dispatch]; +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button + on_click={() => { + alert(`Sending "${message}" to ${contact.email}`); + dispatch({ + type: "sent_message", + }); + }} + > + Send to {contact.email} + </button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +<Solution> + +Dispatching an action calls a reducer with the current state and the action, and stores the result as the next state. This is what it looks like in code: + +```js +import { useReducer } from "./MyReact.js"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; +import { initialState, messengerReducer } from "./messengerReducer"; + +export default function Messenger() { + const [state, dispatch] = useReducer(messengerReducer, initialState); + const message = state.messages[state.selectedId]; + const contact = contacts.find((c) => c.id === state.selectedId); + return ( + <div> + <ContactList + contacts={contacts} + selectedId={state.selectedId} + dispatch={dispatch} + /> + <Chat + key={contact.id} + message={message} + contact={contact} + dispatch={dispatch} + /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export const initialState = { + selectedId: 0, + messages: { + 0: "Hello, Taylor", + 1: "Hello, Alice", + 2: "Hello, Bob", + }, +}; + +export function messengerReducer(state, action) { + switch (action.type) { + case "changed_selection": { + return { + ...state, + selectedId: action.contactId, + }; + } + case "edited_message": { + return { + ...state, + messages: { + ...state.messages, + [state.selectedId]: action.message, + }, + }; + } + case "sent_message": { + return { + ...state, + messages: { + ...state.messages, + [state.selectedId]: "", + }, + }; + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} +``` + +```js +import { useState } from "react"; + +export function useReducer(reducer, initialState) { + const [state, setState] = useState(initialState); + + function dispatch(action) { + const nextState = reducer(state, action); + setState(nextState); + } + + return [state, dispatch]; +} +``` + +```js +export default function ContactList({ contacts, selectedId, dispatch }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + dispatch({ + type: "changed_selection", + contactId: contact.id, + }); + }} + > + {selectedId === contact.id ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact, message, dispatch }) { + return ( + <section className="chat"> + <textarea + value={message} + placeholder={"Chat to " + contact.name} + onChange={(e) => { + dispatch({ + type: "edited_message", + message: e.target.value, + }); + }} + /> + <br /> + <button + on_click={() => { + alert(`Sending "${message}" to ${contact.email}`); + dispatch({ + type: "sent_message", + }); + }} + > + Send to {contact.email} + </button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +Though it doesn't matter in most cases, a slightly more accurate implementation looks like this: + +```js +function dispatch(action) { + setState((s) => reducer(s, action)); +} +``` + +This is because the dispatched actions are queued until the next render, [similar to the updater functions.](/learn/queueing-a-series-of-state-updates) + +</Solution> + +</Challenges> diff --git a/docs/src/learn/importing-and-exporting-components.md b/docs/src/learn/importing-and-exporting-components.md new file mode 100644 index 000000000..e69853c7b --- /dev/null +++ b/docs/src/learn/importing-and-exporting-components.md @@ -0,0 +1,378 @@ +## Overview + +<p class="intro" markdown> + +The magic of components lies in their reusability: you can create components that are composed of other components. But as you nest more and more components, it often makes sense to start splitting them into different files. This lets you keep your files easy to scan and reuse components in more places. + +</p> + +!!! summary "You will learn" + + - What a root component file is + - How to import and export a component + - When to use default and named imports and exports + - How to import and export multiple components from one file + - How to split components into multiple files + +## The root component file + +In [Your First Component](/learn/your-first-component), you made a `Profile` component and a `Gallery` component that renders it: + +```js +function Profile() { + return ( + <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" /> + ); +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +These currently live in a **root component file,** named `App.js` in this example. In [Create React App](https://create-react-app.dev/), your app lives in `src/App.js`. Depending on your setup, your root component could be in another file, though. If you use a framework with file-based routing, such as Next.js, your root component will be different for every page. + +## Exporting and importing a component + +What if you want to change the landing screen in the future and put a list of science books there? Or place all the profiles somewhere else? It makes sense to move `Gallery` and `Profile` out of the root component file. This will make them more modular and reusable in other files. You can move a component in three steps: + +1. **Make** a new JS file to put the components in. +2. **Export** your function component from that file (using either [default](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/export#using_the_default_export) or [named](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/export#using_named_exports) exports). +3. **Import** it in the file where you’ll use the component (using the corresponding technique for importing [default](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/import#importing_defaults) or [named](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/import#import_a_single_export_from_a_module) exports). + +Here both `Profile` and `Gallery` have been moved out of `App.js` into a new file called `Gallery.js`. Now you can change `App.js` to import `Gallery` from `Gallery.js`: + +```js +import Gallery from "./Gallery.js"; + +export default function App() { + return <Gallery />; +} +``` + +```js +function Profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +Notice how this example is broken down into two component files now: + +1. `Gallery.js`: + - Defines the `Profile` component which is only used within the same file and is not exported. + - Exports the `Gallery` component as a **default export.** +2. `App.js`: + - Imports `Gallery` as a **default import** from `Gallery.js`. + - Exports the root `App` component as a **default export.** + +<Note> + +You may encounter files that leave off the `.js` file extension like so: + +```js +import Gallery from "./Gallery"; +``` + +Either `'./Gallery.js'` or `'./Gallery'` will work with React, though the former is closer to how [native ES Modules](https://developer.mozilla.org/docs/Web/JavaScript/Guide/Modules) work. + +</Note> + +<DeepDive> + +#### Default vs named exports + +There are two primary ways to export values with JavaScript: default exports and named exports. So far, our examples have only used default exports. But you can use one or both of them in the same file. **A file can have no more than one _default_ export, but it can have as many _named_ exports as you like.** + + + +How you export your component dictates how you must import it. You will get an error if you try to import a default export the same way you would a named export! This chart can help you keep track: + +| Syntax | Export statement | Import statement | +| --- | --- | --- | +| Default | `export default function Button() {}` | `import Button from './Button.js';` | +| Named | `export function Button() {}` | `import { Button } from './Button.js';` | + +When you write a _default_ import, you can put any name you want after `import`. For example, you could write `import Banana from './Button.js'` instead and it would still provide you with the same default export. In contrast, with named imports, the name has to match on both sides. That's why they are called _named_ imports! + +**People often use default exports if the file exports only one component, and use named exports if it exports multiple components and values.** Regardless of which coding style you prefer, always give meaningful names to your component functions and the files that contain them. Components without names, like `export default () => {}`, are discouraged because they make debugging harder. + +</DeepDive> + +## Exporting and importing multiple components from the same file + +What if you want to show just one `Profile` instead of a gallery? You can export the `Profile` component, too. But `Gallery.js` already has a _default_ export, and you can't have _two_ default exports. You could create a new file with a default export, or you could add a _named_ export for `Profile`. **A file can only have one default export, but it can have numerous named exports!** + +<Note> + +To reduce the potential confusion between default and named exports, some teams choose to only stick to one style (default or named), or avoid mixing them in a single file. Do what works best for you! + +</Note> + +First, **export** `Profile` from `Gallery.js` using a named export (no `default` keyword): + +```js +export function Profile() { + // ... +} +``` + +Then, **import** `Profile` from `Gallery.js` to `App.js` using a named import (with the curly braces): + +```js +import { Profile } from "./Gallery.js"; +``` + +Finally, **render** `<Profile />` from the `App` component: + +```js +export default function App() { + return <Profile />; +} +``` + +Now `Gallery.js` contains two exports: a default `Gallery` export, and a named `Profile` export. `App.js` imports both of them. Try editing `<Profile />` to `<Gallery />` and back in this example: + +```js +import Gallery from "./Gallery.js"; +import { Profile } from "./Gallery.js"; + +export default function App() { + return <Profile />; +} +``` + +```js +export function Profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +Now you're using a mix of default and named exports: + +- `Gallery.js`: + - Exports the `Profile` component as a **named export called `Profile`.** + - Exports the `Gallery` component as a **default export.** +- `App.js`: + - Imports `Profile` as a **named import called `Profile`** from `Gallery.js`. + - Imports `Gallery` as a **default import** from `Gallery.js`. + - Exports the root `App` component as a **default export.** + +<Recap> + +On this page you learned: + +- What a root component file is +- How to import and export a component +- When and how to use default and named imports and exports +- How to export multiple components from the same file + +</Recap> + +<Challenges> + +#### Split the components further + +Currently, `Gallery.js` exports both `Profile` and `Gallery`, which is a bit confusing. + +Move the `Profile` component to its own `Profile.js`, and then change the `App` component to render both `<Profile />` and `<Gallery />` one after another. + +You may use either a default or a named export for `Profile`, but make sure that you use the corresponding import syntax in both `App.js` and `Gallery.js`! You can refer to the table from the deep dive above: + +| Syntax | Export statement | Import statement | +| --- | --- | --- | +| Default | `export default function Button() {}` | `import Button from './Button.js';` | +| Named | `export function Button() {}` | `import { Button } from './Button.js';` | + +<Hint> + +Don't forget to import your components where they are called. Doesn't `Gallery` use `Profile`, too? + +</Hint> + +```js +import Gallery from "./Gallery.js"; +import { Profile } from "./Gallery.js"; + +export default function App() { + return ( + <div> + <Profile /> + </div> + ); +} +``` + +```js +// Move me to Profile.js! +export function Profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```js + +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +After you get it working with one kind of exports, make it work with the other kind. + +<Solution> + +This is the solution with named exports: + +```js +import Gallery from "./Gallery.js"; +import { Profile } from "./Profile.js"; + +export default function App() { + return ( + <div> + <Profile /> + <Gallery /> + </div> + ); +} +``` + +```js +import { Profile } from "./Profile.js"; + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```js +export function Profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +This is the solution with default exports: + +```js +import Gallery from "./Gallery.js"; +import Profile from "./Profile.js"; + +export default function App() { + return ( + <div> + <Profile /> + <Gallery /> + </div> + ); +} +``` + +```js +import Profile from "./Profile.js"; + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```js +export default function Profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/installation.md b/docs/src/learn/installation.md new file mode 100644 index 000000000..48441a7cb --- /dev/null +++ b/docs/src/learn/installation.md @@ -0,0 +1,30 @@ +## Overview + +<p class="intro" markdown> + +React has been designed from the start for gradual adoption. You can use as little or as much React as you need. Whether you want to get a taste of React, add some interactivity to an HTML page, or start a complex React-powered app, this section will help you get started. + +</p> + +!!! summary "You will learn" + + * [How to start a new React project](../learn/start-a-new-react-project.md) + * [How to add React to an existing project](../learn/add-react-to-an-existing-project.md) + * [How to set up your editor](../learn/editor-setup.md) + * [How to install React Developer Tools](../learn/react-developer-tools.md) + +<!-- ## Try React + +You don't need to install anything to play with React. [Try ReactPy within Jupyter Notebooks](https://mybinder.org/v2/gh/reactive-python/reactpy-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb)! --> + +## Start a new React project + +If you want to build an app or a website fully with React, [start a new React project.](../learn/start-a-new-react-project.md) + +## Add React to an existing project + +If want to try using React in your existing app or a website, [add React to an existing project.](../learn/add-react-to-an-existing-project.md) + +## Next steps + +Head to the [Quick Start](../learn/quick-start.md) guide for a tour of the most important React concepts you will encounter every day. diff --git a/docs/src/learn/keeping-components-pure.md b/docs/src/learn/keeping-components-pure.md new file mode 100644 index 000000000..833527bdd --- /dev/null +++ b/docs/src/learn/keeping-components-pure.md @@ -0,0 +1,811 @@ +## Overview + +<p class="intro" markdown> + +Some JavaScript functions are _pure._ Pure functions only perform a calculation and nothing more. By strictly only writing your components as pure functions, you can avoid an entire class of baffling bugs and unpredictable behavior as your codebase grows. To get these benefits, though, there are a few rules you must follow. + +</p> + +!!! summary "You will learn" + + - What purity is and how it helps you avoid bugs + - How to keep components pure by keeping changes out of the render phase + - How to use Strict Mode to find mistakes in your components + +## Purity: Components as formulas + +In computer science (and especially the world of functional programming), [a pure function](https://wikipedia.org/wiki/Pure_function) is a function with the following characteristics: + +- **It minds its own business.** It does not change any objects or variables that existed before it was called. +- **Same inputs, same output.** Given the same inputs, a pure function should always return the same result. + +You might already be familiar with one example of pure functions: formulas in math. + +Consider this math formula: <Math><MathI>y</MathI> = 2<MathI>x</MathI></Math>. + +If <Math><MathI>x</MathI> = 2</Math> then <Math><MathI>y</MathI> = 4</Math>. Always. + +If <Math><MathI>x</MathI> = 3</Math> then <Math><MathI>y</MathI> = 6</Math>. Always. + +If <Math><MathI>x</MathI> = 3</Math>, <MathI>y</MathI> won't sometimes be <Math>9</Math> or <Math>–1</Math> or <Math>2.5</Math> depending on the time of day or the state of the stock market. + +If <Math><MathI>y</MathI> = 2<MathI>x</MathI></Math> and <Math><MathI>x</MathI> = 3</Math>, <MathI>y</MathI> will _always_ be <Math>6</Math>. + +If we made this into a JavaScript function, it would look like this: + +```js +function double(number) { + return 2 * number; +} +``` + +In the above example, `double` is a **pure function.** If you pass it `3`, it will return `6`. Always. + +React is designed around this concept. **React assumes that every component you write is a pure function.** This means that React components you write must always return the same JSX given the same inputs: + +```js +function Recipe({ drinkers }) { + return ( + <ol> + <li>Boil {drinkers} cups of water.</li> + <li> + Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of + spice. + </li> + <li> + Add {0.5 * drinkers} cups of milk to boil and sugar to taste. + </li> + </ol> + ); +} + +export default function App() { + return ( + <section> + <h1>Spiced Chai Recipe</h1> + <h2>For two</h2> + <Recipe drinkers={2} /> + <h2>For a gathering</h2> + <Recipe drinkers={4} /> + </section> + ); +} +``` + +When you pass `drinkers={2}` to `Recipe`, it will return JSX containing `2 cups of water`. Always. + +If you pass `drinkers={4}`, it will return JSX containing `4 cups of water`. Always. + +Just like a math formula. + +You could think of your components as recipes: if you follow them and don't introduce new ingredients during the cooking process, you will get the same dish every time. That "dish" is the JSX that the component serves to React to [render.](/learn/render-and-commit) + +<Illustration src="/images/docs/illustrations/i_puritea-recipe.png" alt="A tea recipe for x people: take x cups of water, add x spoons of tea and 0.5x spoons of spices, and 0.5x cups of milk" /> + +## Side Effects: (un)intended consequences + +React's rendering process must always be pure. Components should only _return_ their JSX, and not _change_ any objects or variables that existed before rendering—that would make them impure! + +Here is a component that breaks this rule: + +```js +let guest = 0; + +function Cup() { + // Bad: changing a preexisting variable! + guest = guest + 1; + return <h2>Tea cup for guest #{guest}</h2>; +} + +export default function TeaSet() { + return ( + <> + <Cup /> + <Cup /> + <Cup /> + </> + ); +} +``` + +This component is reading and writing a `guest` variable declared outside of it. This means that **calling this component multiple times will produce different JSX!** And what's more, if _other_ components read `guest`, they will produce different JSX, too, depending on when they were rendered! That's not predictable. + +Going back to our formula <Math><MathI>y</MathI> = 2<MathI>x</MathI></Math>, now even if <Math><MathI>x</MathI> = 2</Math>, we cannot trust that <Math><MathI>y</MathI> = 4</Math>. Our tests could fail, our users would be baffled, planes would fall out of the sky—you can see how this would lead to confusing bugs! + +You can fix this component by [passing `guest` as a prop instead](/learn/passing-props-to-a-component): + +```js +function Cup({ guest }) { + return <h2>Tea cup for guest #{guest}</h2>; +} + +export default function TeaSet() { + return ( + <> + <Cup guest={1} /> + <Cup guest={2} /> + <Cup guest={3} /> + </> + ); +} +``` + +Now your component is pure, as the JSX it returns only depends on the `guest` prop. + +In general, you should not expect your components to be rendered in any particular order. It doesn't matter if you call <Math><MathI>y</MathI> = 2<MathI>x</MathI></Math> before or after <Math><MathI>y</MathI> = 5<MathI>x</MathI></Math>: both formulas will resolve independently of each other. In the same way, each component should only "think for itself", and not attempt to coordinate with or depend upon others during rendering. Rendering is like a school exam: each component should calculate JSX on their own! + +<DeepDive> + +#### Detecting impure calculations with StrictMode + +Although you might not have used them all yet, in React there are three kinds of inputs that you can read while rendering: [props](/learn/passing-props-to-a-component), [state](/learn/state-a-components-memory), and [context.](/learn/passing-data-deeply-with-context) You should always treat these inputs as read-only. + +When you want to _change_ something in response to user input, you should [set state](/learn/state-a-components-memory) instead of writing to a variable. You should never change preexisting variables or objects while your component is rendering. + +React offers a "Strict Mode" in which it calls each component's function twice during development. **By calling the component functions twice, Strict Mode helps find components that break these rules.** + +Notice how the original example displayed "Guest #2", "Guest #4", and "Guest #6" instead of "Guest #1", "Guest #2", and "Guest #3". The original function was impure, so calling it twice broke it. But the fixed pure version works even if the function is called twice every time. **Pure functions only calculate, so calling them twice won't change anything**--just like calling `double(2)` twice doesn't change what's returned, and solving <Math><MathI>y</MathI> = 2<MathI>x</MathI></Math> twice doesn't change what <MathI>y</MathI> is. Same inputs, same outputs. Always. + +Strict Mode has no effect in production, so it won't slow down the app for your users. To opt into Strict Mode, you can wrap your root component into `<React.StrictMode>`. Some frameworks do this by default. + +</DeepDive> + +### Local mutation: Your component's little secret + +In the above example, the problem was that the component changed a _preexisting_ variable while rendering. This is often called a **"mutation"** to make it sound a bit scarier. Pure functions don't mutate variables outside of the function's scope or objects that were created before the call—that makes them impure! + +However, **it's completely fine to change variables and objects that you've _just_ created while rendering.** In this example, you create an `[]` array, assign it to a `cups` variable, and then `push` a dozen cups into it: + +```js +function Cup({ guest }) { + return <h2>Tea cup for guest #{guest}</h2>; +} + +export default function TeaGathering() { + let cups = []; + for (let i = 1; i <= 12; i++) { + cups.push(<Cup key={i} guest={i} />); + } + return cups; +} +``` + +If the `cups` variable or the `[]` array were created outside the `TeaGathering` function, this would be a huge problem! You would be changing a _preexisting_ object by pushing items into that array. + +However, it's fine because you've created them _during the same render_, inside `TeaGathering`. No code outside of `TeaGathering` will ever know that this happened. This is called **"local mutation"**—it's like your component's little secret. + +## Where you _can_ cause side effects + +While functional programming relies heavily on purity, at some point, somewhere, _something_ has to change. That's kind of the point of programming! These changes—updating the screen, starting an animation, changing the data—are called **side effects.** They're things that happen _"on the side"_, not during rendering. + +In React, **side effects usually belong inside [event handlers.](/learn/responding-to-events)** Event handlers are functions that React runs when you perform some action—for example, when you click a button. Even though event handlers are defined _inside_ your component, they don't run _during_ rendering! **So event handlers don't need to be pure.** + +If you've exhausted all other options and can't find the right event handler for your side effect, you can still attach it to your returned JSX with a [`useEffect`](/reference/react/useEffect) call in your component. This tells React to execute it later, after rendering, when side effects are allowed. **However, this approach should be your last resort.** + +When possible, try to express your logic with rendering alone. You'll be surprised how far this can take you! + +<DeepDive> + +#### Why does React care about purity? + +Writing pure functions takes some habit and discipline. But it also unlocks marvelous opportunities: + +- Your components could run in a different environment—for example, on the server! Since they return the same result for the same inputs, one component can serve many user requests. +- You can improve performance by [skipping rendering](/reference/react/memo) components whose inputs have not changed. This is safe because pure functions always return the same results, so they are safe to cache. +- If some data changes in the middle of rendering a deep component tree, React can restart rendering without wasting time to finish the outdated render. Purity makes it safe to stop calculating at any time. + +Every new React feature we're building takes advantage of purity. From data fetching to animations to performance, keeping components pure unlocks the power of the React paradigm. + +</DeepDive> + +<Recap> + +- A component must be pure, meaning: + - **It minds its own business.** It should not change any objects or variables that existed before rendering. + - **Same inputs, same output.** Given the same inputs, a component should always return the same JSX. +- Rendering can happen at any time, so components should not depend on each others' rendering sequence. +- You should not mutate any of the inputs that your components use for rendering. That includes props, state, and context. To update the screen, ["set" state](/learn/state-a-components-memory) instead of mutating preexisting objects. +- Strive to express your component's logic in the JSX you return. When you need to "change things", you'll usually want to do it in an event handler. As a last resort, you can `useEffect`. +- Writing pure functions takes a bit of practice, but it unlocks the power of React's paradigm. + +</Recap> + +<Challenges> + +#### Fix a broken clock + +This component tries to set the `<h1>`'s CSS class to `"night"` during the time from midnight to six hours in the morning, and `"day"` at all other times. However, it doesn't work. Can you fix this component? + +You can verify whether your solution works by temporarily changing the computer's timezone. When the current time is between midnight and six in the morning, the clock should have inverted colors! + +<Hint> + +Rendering is a _calculation_, it shouldn't try to "do" things. Can you express the same idea differently? + +</Hint> + +```js +export default function Clock({ time }) { + let hours = time.getHours(); + if (hours >= 0 && hours <= 6) { + document.getElementById("time").className = "night"; + } else { + document.getElementById("time").className = "day"; + } + return <h1 id="time">{time.toLocaleTimeString()}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + return <Clock time={time} />; +} +``` + +```css +body > * { + width: 100%; + height: 100%; +} +.day { + background: #fff; + color: #222; +} +.night { + background: #222; + color: #fff; +} +``` + +<Solution> + +You can fix this component by calculating the `className` and including it in the render output: + +```js +export default function Clock({ time }) { + let hours = time.getHours(); + let className; + if (hours >= 0 && hours <= 6) { + className = "night"; + } else { + className = "day"; + } + return <h1 className={className}>{time.toLocaleTimeString()}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + return <Clock time={time} />; +} +``` + +```css +body > * { + width: 100%; + height: 100%; +} +.day { + background: #fff; + color: #222; +} +.night { + background: #222; + color: #fff; +} +``` + +In this example, the side effect (modifying the DOM) was not necessary at all. You only needed to return JSX. + +</Solution> + +#### Fix a broken profile + +Two `Profile` components are rendered side by side with different data. Press "Collapse" on the first profile, and then "Expand" it. You'll notice that both profiles now show the same person. This is a bug. + +Find the cause of the bug and fix it. + +<Hint> + +The buggy code is in `Profile.js`. Make sure you read it all from top to bottom! + +</Hint> + +```js +import Panel from "./Panel.js"; +import { getImageUrl } from "./utils.js"; + +let currentPerson; + +export default function Profile({ person }) { + currentPerson = person; + return ( + <Panel> + <Header /> + <Avatar /> + </Panel> + ); +} + +function Header() { + return <h1>{currentPerson.name}</h1>; +} + +function Avatar() { + return ( + <img + className="avatar" + src={getImageUrl(currentPerson)} + alt={currentPerson.name} + width={50} + height={50} + /> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Panel({ children }) { + const [open, setOpen] = useState(true); + return ( + <section className="panel"> + <button on_click={() => setOpen(!open)}> + {open ? "Collapse" : "Expand"} + </button> + {open && children} + </section> + ); +} +``` + +```js +import Profile from "./Profile.js"; + +export default function App() { + return ( + <> + <Profile + person={{ + imageId: "lrWQx8l", + name: "Subrahmanyan Chandrasekhar", + }} + /> + <Profile + person={{ + imageId: "MK3eW3A", + name: "Creola Katherine Johnson", + }} + /> + </> + ); +} +``` + +```js +export function getImageUrl(person, size = "s") { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 5px; + border-radius: 50%; +} +.panel { + border: 1px solid #aaa; + border-radius: 6px; + margin-top: 20px; + padding: 10px; + width: 200px; +} +h1 { + margin: 5px; + font-size: 18px; +} +``` + +<Solution> + +The problem is that the `Profile` component writes to a preexisting variable called `currentPerson`, and the `Header` and `Avatar` components read from it. This makes _all three of them_ impure and difficult to predict. + +To fix the bug, remove the `currentPerson` variable. Instead, pass all information from `Profile` to `Header` and `Avatar` via props. You'll need to add a `person` prop to both components and pass it all the way down. + +```js +import Panel from "./Panel.js"; +import { getImageUrl } from "./utils.js"; + +export default function Profile({ person }) { + return ( + <Panel> + <Header person={person} /> + <Avatar person={person} /> + </Panel> + ); +} + +function Header({ person }) { + return <h1>{person.name}</h1>; +} + +function Avatar({ person }) { + return ( + <img + className="avatar" + src={getImageUrl(person)} + alt={person.name} + width={50} + height={50} + /> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Panel({ children }) { + const [open, setOpen] = useState(true); + return ( + <section className="panel"> + <button on_click={() => setOpen(!open)}> + {open ? "Collapse" : "Expand"} + </button> + {open && children} + </section> + ); +} +``` + +```js +import Profile from "./Profile.js"; + +export default function App() { + return ( + <> + <Profile + person={{ + imageId: "lrWQx8l", + name: "Subrahmanyan Chandrasekhar", + }} + /> + <Profile + person={{ + imageId: "MK3eW3A", + name: "Creola Katherine Johnson", + }} + /> + </> + ); +} +``` + +```js +export function getImageUrl(person, size = "s") { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 5px; + border-radius: 50%; +} +.panel { + border: 1px solid #aaa; + border-radius: 6px; + margin-top: 20px; + padding: 10px; + width: 200px; +} +h1 { + margin: 5px; + font-size: 18px; +} +``` + +Remember that React does not guarantee that component functions will execute in any particular order, so you can't communicate between them by setting variables. All communication must happen through props. + +</Solution> + +#### Fix a broken story tray + +The CEO of your company is asking you to add "stories" to your online clock app, and you can't say no. You've written a `StoryTray` component that accepts a list of `stories`, followed by a "Create Story" placeholder. + +You implemented the "Create Story" placeholder by pushing one more fake story at the end of the `stories` array that you receive as a prop. But for some reason, "Create Story" appears more than once. Fix the issue. + +```js +export default function StoryTray({ stories }) { + stories.push({ + id: "create", + label: "Create Story", + }); + + return ( + <ul> + {stories.map((story) => ( + <li key={story.id}>{story.label}</li> + ))} + </ul> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import StoryTray from "./StoryTray.js"; + +let initialStories = [ + { id: 0, label: "Ankit's Story" }, + { id: 1, label: "Taylor's Story" }, +]; + +export default function App() { + let [stories, setStories] = useState([...initialStories]); + let time = useTime(); + + // HACK: Prevent the memory from growing forever while you read docs. + // We're breaking our own rules here. + if (stories.length > 100) { + stories.length = 100; + } + + return ( + <div + style={{ + width: "100%", + height: "100%", + textAlign: "center", + }} + > + <h2>It is {time.toLocaleTimeString()} now.</h2> + <StoryTray stories={stories} /> + </div> + ); +} + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} +``` + +```css +ul { + margin: 0; + list-style-type: none; +} + +li { + border: 1px solid #aaa; + border-radius: 6px; + float: left; + margin: 5px; + margin-bottom: 20px; + padding: 5px; + width: 70px; + height: 100px; +} +``` + +```js +{ + "hardReloadOnChange": true +} +``` + +<Solution> + +Notice how whenever the clock updates, "Create Story" is added _twice_. This serves as a hint that we have a mutation during rendering--Strict Mode calls components twice to make these issues more noticeable. + +`StoryTray` function is not pure. By calling `push` on the received `stories` array (a prop!), it is mutating an object that was created _before_ `StoryTray` started rendering. This makes it buggy and very difficult to predict. + +The simplest fix is to not touch the array at all, and render "Create Story" separately: + +```js +export default function StoryTray({ stories }) { + return ( + <ul> + {stories.map((story) => ( + <li key={story.id}>{story.label}</li> + ))} + <li>Create Story</li> + </ul> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import StoryTray from "./StoryTray.js"; + +let initialStories = [ + { id: 0, label: "Ankit's Story" }, + { id: 1, label: "Taylor's Story" }, +]; + +export default function App() { + let [stories, setStories] = useState([...initialStories]); + let time = useTime(); + + // HACK: Prevent the memory from growing forever while you read docs. + // We're breaking our own rules here. + if (stories.length > 100) { + stories.length = 100; + } + + return ( + <div + style={{ + width: "100%", + height: "100%", + textAlign: "center", + }} + > + <h2>It is {time.toLocaleTimeString()} now.</h2> + <StoryTray stories={stories} /> + </div> + ); +} + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} +``` + +```css +ul { + margin: 0; + list-style-type: none; +} + +li { + border: 1px solid #aaa; + border-radius: 6px; + float: left; + margin: 5px; + margin-bottom: 20px; + padding: 5px; + width: 70px; + height: 100px; +} +``` + +Alternatively, you could create a _new_ array (by copying the existing one) before you push an item into it: + +```js +export default function StoryTray({ stories }) { + // Copy the array! + let storiesToDisplay = stories.slice(); + + // Does not affect the original array: + storiesToDisplay.push({ + id: "create", + label: "Create Story", + }); + + return ( + <ul> + {storiesToDisplay.map((story) => ( + <li key={story.id}>{story.label}</li> + ))} + </ul> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import StoryTray from "./StoryTray.js"; + +let initialStories = [ + { id: 0, label: "Ankit's Story" }, + { id: 1, label: "Taylor's Story" }, +]; + +export default function App() { + let [stories, setStories] = useState([...initialStories]); + let time = useTime(); + + // HACK: Prevent the memory from growing forever while you read docs. + // We're breaking our own rules here. + if (stories.length > 100) { + stories.length = 100; + } + + return ( + <div + style={{ + width: "100%", + height: "100%", + textAlign: "center", + }} + > + <h2>It is {time.toLocaleTimeString()} now.</h2> + <StoryTray stories={stories} /> + </div> + ); +} + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} +``` + +```css +ul { + margin: 0; + list-style-type: none; +} + +li { + border: 1px solid #aaa; + border-radius: 6px; + float: left; + margin: 5px; + margin-bottom: 20px; + padding: 5px; + width: 70px; + height: 100px; +} +``` + +This keeps your mutation local and your rendering function pure. However, you still need to be careful: for example, if you tried to change any of the array's existing items, you'd have to clone those items too. + +It is useful to remember which operations on arrays mutate them, and which don't. For example, `push`, `pop`, `reverse`, and `sort` will mutate the original array, but `slice`, `filter`, and `map` will create a new one. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/lifecycle-of-reactive-effects.md b/docs/src/learn/lifecycle-of-reactive-effects.md new file mode 100644 index 000000000..9192d6ad0 --- /dev/null +++ b/docs/src/learn/lifecycle-of-reactive-effects.md @@ -0,0 +1,2248 @@ +## Overview + +<p class="intro" markdown> + +Effects have a different lifecycle from components. Components may mount, update, or unmount. An Effect can only do two things: to start synchronizing something, and later to stop synchronizing it. This cycle can happen multiple times if your Effect depends on props and state that change over time. React provides a linter rule to check that you've specified your Effect's dependencies correctly. This keeps your Effect synchronized to the latest props and state. + +</p> + +!!! summary "You will learn" + + - How an Effect's lifecycle is different from a component's lifecycle + - How to think about each individual Effect in isolation + - When your Effect needs to re-synchronize, and why + - How your Effect's dependencies are determined + - What it means for a value to be reactive + - What an empty dependency array means + - How React verifies your dependencies are correct with a linter + - What to do when you disagree with the linter + +## The lifecycle of an Effect + +Every React component goes through the same lifecycle: + +- A component _mounts_ when it's added to the screen. +- A component _updates_ when it receives new props or state, usually in response to an interaction. +- A component _unmounts_ when it's removed from the screen. + +**It's a good way to think about components, but _not_ about Effects.** Instead, try to think about each Effect independently from your component's lifecycle. An Effect describes how to [synchronize an external system](/learn/synchronizing-with-effects) to the current props and state. As your code changes, synchronization will need to happen more or less often. + +To illustrate this point, consider this Effect connecting your component to a chat server: + +```js +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +Your Effect's body specifies how to **start synchronizing:** + +```js +// ... +const connection = createConnection(serverUrl, roomId); +connection.connect(); +return () => { + connection.disconnect(); +}; +// ... +``` + +The cleanup function returned by your Effect specifies how to **stop synchronizing:** + +```js +// ... +const connection = createConnection(serverUrl, roomId); +connection.connect(); +return () => { + connection.disconnect(); +}; +// ... +``` + +Intuitively, you might think that React would **start synchronizing** when your component mounts and **stop synchronizing** when your component unmounts. However, this is not the end of the story! Sometimes, it may also be necessary to **start and stop synchronizing multiple times** while the component remains mounted. + +Let's look at _why_ this is necessary, _when_ it happens, and _how_ you can control this behavior. + +<Note> + +Some Effects don't return a cleanup function at all. [More often than not,](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) you'll want to return one--but if you don't, React will behave as if you returned an empty cleanup function. + +</Note> + +### Why synchronization may need to happen more than once + +Imagine this `ChatRoom` component receives a `roomId` prop that the user picks in a dropdown. Let's say that initially the user picks the `"general"` room as the `roomId`. Your app displays the `"general"` chat room: + +```js +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId /* "general" */ }) { + // ... + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +After the UI is displayed, React will run your Effect to **start synchronizing.** It connects to the `"general"` room: + +```js +function ChatRoom({ roomId /* "general" */ }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Connects to the "general" room + connection.connect(); + return () => { + connection.disconnect(); // Disconnects from the "general" room + }; + }, [roomId]); + // ... +``` + +So far, so good. + +Later, the user picks a different room in the dropdown (for example, `"travel"`). First, React will update the UI: + +```js +function ChatRoom({ roomId /* "travel" */ }) { + // ... + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +Think about what should happen next. The user sees that `"travel"` is the selected chat room in the UI. However, the Effect that ran the last time is still connected to the `"general"` room. **The `roomId` prop has changed, so what your Effect did back then (connecting to the `"general"` room) no longer matches the UI.** + +At this point, you want React to do two things: + +1. Stop synchronizing with the old `roomId` (disconnect from the `"general"` room) +2. Start synchronizing with the new `roomId` (connect to the `"travel"` room) + +**Luckily, you've already taught React how to do both of these things!** Your Effect's body specifies how to start synchronizing, and your cleanup function specifies how to stop synchronizing. All that React needs to do now is to call them in the correct order and with the correct props and state. Let's see how exactly that happens. + +### How React re-synchronizes your Effect + +Recall that your `ChatRoom` component has received a new value for its `roomId` prop. It used to be `"general"`, and now it is `"travel"`. React needs to re-synchronize your Effect to re-connect you to a different room. + +To **stop synchronizing,** React will call the cleanup function that your Effect returned after connecting to the `"general"` room. Since `roomId` was `"general"`, the cleanup function disconnects from the `"general"` room: + +```js +function ChatRoom({ roomId /* "general" */ }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Connects to the "general" room + connection.connect(); + return () => { + connection.disconnect(); // Disconnects from the "general" room + }; + // ... +``` + +Then React will run the Effect that you've provided during this render. This time, `roomId` is `"travel"` so it will **start synchronizing** to the `"travel"` chat room (until its cleanup function is eventually called too): + +```js +function ChatRoom({ roomId /* "travel" */ }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room + connection.connect(); + // ... +``` + +Thanks to this, you're now connected to the same room that the user chose in the UI. Disaster averted! + +Every time after your component re-renders with a different `roomId`, your Effect will re-synchronize. For example, let's say the user changes `roomId` from `"travel"` to `"music"`. React will again **stop synchronizing** your Effect by calling its cleanup function (disconnecting you from the `"travel"` room). Then it will **start synchronizing** again by running its body with the new `roomId` prop (connecting you to the `"music"` room). + +Finally, when the user goes to a different screen, `ChatRoom` unmounts. Now there is no need to stay connected at all. React will **stop synchronizing** your Effect one last time and disconnect you from the `"music"` chat room. + +### Thinking from the Effect's perspective + +Let's recap everything that's happened from the `ChatRoom` component's perspective: + +1. `ChatRoom` mounted with `roomId` set to `"general"` +1. `ChatRoom` updated with `roomId` set to `"travel"` +1. `ChatRoom` updated with `roomId` set to `"music"` +1. `ChatRoom` unmounted + +During each of these points in the component's lifecycle, your Effect did different things: + +1. Your Effect connected to the `"general"` room +1. Your Effect disconnected from the `"general"` room and connected to the `"travel"` room +1. Your Effect disconnected from the `"travel"` room and connected to the `"music"` room +1. Your Effect disconnected from the `"music"` room + +Now let's think about what happened from the perspective of the Effect itself: + +```js +useEffect(() => { + // Your Effect connected to the room specified with roomId... + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + // ...until it disconnected + connection.disconnect(); + }; +}, [roomId]); +``` + +This code's structure might inspire you to see what happened as a sequence of non-overlapping time periods: + +1. Your Effect connected to the `"general"` room (until it disconnected) +1. Your Effect connected to the `"travel"` room (until it disconnected) +1. Your Effect connected to the `"music"` room (until it disconnected) + +Previously, you were thinking from the component's perspective. When you looked from the component's perspective, it was tempting to think of Effects as "callbacks" or "lifecycle events" that fire at a specific time like "after a render" or "before unmount". This way of thinking gets complicated very fast, so it's best to avoid. + +**Instead, always focus on a single start/stop cycle at a time. It shouldn't matter whether a component is mounting, updating, or unmounting. All you need to do is to describe how to start synchronization and how to stop it. If you do it well, your Effect will be resilient to being started and stopped as many times as it's needed.** + +This might remind you how you don't think whether a component is mounting or updating when you write the rendering logic that creates JSX. You describe what should be on the screen, and React [figures out the rest.](/learn/reacting-to-input-with-state) + +### How React verifies that your Effect can re-synchronize + +Here is a live example that you can play with. Press "Open chat" to mount the `ChatRoom` component: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [show, setShow] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <button on_click={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Notice that when the component mounts for the first time, you see three logs: + +1. `✅ Connecting to "general" room at https://localhost:1234...` _(development-only)_ +1. `❌ Disconnected from "general" room at https://localhost:1234.` _(development-only)_ +1. `✅ Connecting to "general" room at https://localhost:1234...` + +The first two logs are development-only. In development, React always remounts each component once. + +**React verifies that your Effect can re-synchronize by forcing it to do that immediately in development.** This might remind you of opening a door and closing it an extra time to check if the door lock works. React starts and stops your Effect one extra time in development to check [you've implemented its cleanup well.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +The main reason your Effect will re-synchronize in practice is if some data it uses has changed. In the sandbox above, change the selected chat room. Notice how, when the `roomId` changes, your Effect re-synchronizes. + +However, there are also more unusual cases in which re-synchronization is necessary. For example, try editing the `serverUrl` in the sandbox above while the chat is open. Notice how the Effect re-synchronizes in response to your edits to the code. In the future, React may add more features that rely on re-synchronization. + +### How React knows that it needs to re-synchronize the Effect + +You might be wondering how React knew that your Effect needed to re-synchronize after `roomId` changes. It's because _you told React_ that its code depends on `roomId` by including it in the [list of dependencies:](/learn/synchronizing-with-effects#step-2-specify-the-effect-dependencies) + +```js +function ChatRoom({ roomId }) { // The roomId prop may change over time + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // This Effect reads roomId + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); // So you tell React that this Effect "depends on" roomId + // ... +``` + +Here's how this works: + +1. You knew `roomId` is a prop, which means it can change over time. +2. You knew that your Effect reads `roomId` (so its logic depends on a value that may change later). +3. This is why you specified it as your Effect's dependency (so that it re-synchronizes when `roomId` changes). + +Every time after your component re-renders, React will look at the array of dependencies that you have passed. If any of the values in the array is different from the value at the same spot that you passed during the previous render, React will re-synchronize your Effect. + +For example, if you passed `["general"]` during the initial render, and later you passed `["travel"]` during the next render, React will compare `"general"` and `"travel"`. These are different values (compared with [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)), so React will re-synchronize your Effect. On the other hand, if your component re-renders but `roomId` has not changed, your Effect will remain connected to the same room. + +### Each Effect represents a separate synchronization process + +Resist adding unrelated logic to your Effect only because this logic needs to run at the same time as an Effect you already wrote. For example, let's say you want to send an analytics event when the user visits the room. You already have an Effect that depends on `roomId`, so you might feel tempted to add the analytics call there: + +```js +function ChatRoom({ roomId }) { + useEffect(() => { + logVisit(roomId); + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +But imagine you later add another dependency to this Effect that needs to re-establish the connection. If this Effect re-synchronizes, it will also call `logVisit(roomId)` for the same room, which you did not intend. Logging the visit **is a separate process** from connecting. Write them as two separate Effects: + +```js +function ChatRoom({ roomId }) { + useEffect(() => { + logVisit(roomId); + }, [roomId]); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + // ... + }, [roomId]); + // ... +} +``` + +**Each Effect in your code should represent a separate and independent synchronization process.** + +In the above example, deleting one Effect wouldn’t break the other Effect's logic. This is a good indication that they synchronize different things, and so it made sense to split them up. On the other hand, if you split up a cohesive piece of logic into separate Effects, the code may look "cleaner" but will be [more difficult to maintain.](/learn/you-might-not-need-an-effect#chains-of-computations) This is why you should think whether the processes are same or separate, not whether the code looks cleaner. + +## Effects "react" to reactive values + +Your Effect reads two variables (`serverUrl` and `roomId`), but you only specified `roomId` as a dependency: + +```js +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +Why doesn't `serverUrl` need to be a dependency? + +This is because the `serverUrl` never changes due to a re-render. It's always the same no matter how many times the component re-renders and why. Since `serverUrl` never changes, it wouldn't make sense to specify it as a dependency. After all, dependencies only do something when they change over time! + +On the other hand, `roomId` may be different on a re-render. **Props, state, and other values declared inside the component are _reactive_ because they're calculated during rendering and participate in the React data flow.** + +If `serverUrl` was a state variable, it would be reactive. Reactive values must be included in dependencies: + +```js +function ChatRoom({ roomId }) { + // Props change over time + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); // State may change over time + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Your Effect reads props and state + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); // So you tell React that this Effect "depends on" on props and state + // ... +} +``` + +By including `serverUrl` as a dependency, you ensure that the Effect re-synchronizes after it changes. + +Try changing the selected chat room or edit the server URL in this sandbox: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Whenever you change a reactive value like `roomId` or `serverUrl`, the Effect re-connects to the chat server. + +### What an Effect with empty dependencies means + +What happens if you move both `serverUrl` and `roomId` outside the component? + +```js +const serverUrl = "https://localhost:1234"; +const roomId = "general"; + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, []); // ✅ All dependencies declared + // ... +} +``` + +Now your Effect's code does not use _any_ reactive values, so its dependencies can be empty (`[]`). + +Thinking from the component's perspective, the empty `[]` dependency array means this Effect connects to the chat room only when the component mounts, and disconnects only when the component unmounts. (Keep in mind that React would still [re-synchronize it an extra time](#how-react-verifies-that-your-effect-can-re-synchronize) in development to stress-test your logic.) + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; +const roomId = "general"; + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +However, if you [think from the Effect's perspective,](#thinking-from-the-effects-perspective) you don't need to think about mounting and unmounting at all. What's important is you've specified what your Effect does to start and stop synchronizing. Today, it has no reactive dependencies. But if you ever want the user to change `roomId` or `serverUrl` over time (and they would become reactive), your Effect's code won't change. You will only need to add them to the dependencies. + +### All variables declared in the component body are reactive + +Props and state aren't the only reactive values. Values that you calculate from them are also reactive. If the props or state change, your component will re-render, and the values calculated from them will also change. This is why all variables from the component body used by the Effect should be in the Effect dependency list. + +Let's say that the user can pick a chat server in the dropdown, but they can also configure a default server in settings. Suppose you've already put the settings state in a [context](/learn/scaling-up-with-reducer-and-context) so you read the `settings` from that context. Now you calculate the `serverUrl` based on the selected server from props and the default server: + +```js +function ChatRoom({ roomId, selectedServerUrl }) { + // roomId is reactive + const settings = useContext(SettingsContext); // settings is reactive + const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl is reactive + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Your Effect reads roomId and serverUrl + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); // So it needs to re-synchronize when either of them changes! + // ... +} +``` + +In this example, `serverUrl` is not a prop or a state variable. It's a regular variable that you calculate during rendering. But it's calculated during rendering, so it can change due to a re-render. This is why it's reactive. + +**All values inside the component (including props, state, and variables in your component's body) are reactive. Any reactive value can change on a re-render, so you need to include reactive values as Effect's dependencies.** + +In other words, Effects "react" to all values from the component body. + +<DeepDive> + +#### Can global or mutable values be dependencies? + +Mutable values (including global variables) aren't reactive. + +**A mutable value like [`location.pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname) can't be a dependency.** It's mutable, so it can change at any time completely outside of the React rendering data flow. Changing it wouldn't trigger a re-render of your component. Therefore, even if you specified it in the dependencies, React _wouldn't know_ to re-synchronize the Effect when it changes. This also breaks the rules of React because reading mutable data during rendering (which is when you calculate the dependencies) breaks [purity of rendering.](/learn/keeping-components-pure) Instead, you should read and subscribe to an external mutable value with [`useSyncExternalStore`.](/learn/you-might-not-need-an-effect#subscribing-to-an-external-store) + +**A mutable value like [`ref.current`](/reference/react/useRef#reference) or things you read from it also can't be a dependency.** The ref object returned by `useRef` itself can be a dependency, but its `current` property is intentionally mutable. It lets you [keep track of something without triggering a re-render.](/learn/referencing-values-with-refs) But since changing it doesn't trigger a re-render, it's not a reactive value, and React won't know to re-run your Effect when it changes. + +As you'll learn below on this page, a linter will check for these issues automatically. + +</DeepDive> + +### React verifies that you specified every reactive value as a dependency + +If your linter is [configured for React,](/learn/editor-setup#linting) it will check that every reactive value used by your Effect's code is declared as its dependency. For example, this is a lint error because both `roomId` and `serverUrl` are reactive: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + // roomId is reactive + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); // serverUrl is reactive + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // <-- Something's wrong here! + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +This may look like a React error, but really React is pointing out a bug in your code. Both `roomId` and `serverUrl` may change over time, but you're forgetting to re-synchronize your Effect when they change. You will remain connected to the initial `roomId` and `serverUrl` even after the user picks different values in the UI. + +To fix the bug, follow the linter's suggestion to specify `roomId` and `serverUrl` as dependencies of your Effect: + +```js +function ChatRoom({ roomId }) { + // roomId is reactive + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); // serverUrl is reactive + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); // ✅ All dependencies declared + // ... +} +``` + +Try this fix in the sandbox above. Verify that the linter error is gone, and the chat re-connects when needed. + +<Note> + +In some cases, React _knows_ that a value never changes even though it's declared inside the component. For example, the [`set` function](/reference/react/useState#setstate) returned from `useState` and the ref object returned by [`useRef`](/reference/react/useRef) are _stable_--they are guaranteed to not change on a re-render. Stable values aren't reactive, so you may omit them from the list. Including them is allowed: they won't change, so it doesn't matter. + +</Note> + +### What to do when you don't want to re-synchronize + +In the previous example, you've fixed the lint error by listing `roomId` and `serverUrl` as dependencies. + +**However, you could instead "prove" to the linter that these values aren't reactive values,** i.e. that they _can't_ change as a result of a re-render. For example, if `serverUrl` and `roomId` don't depend on rendering and always have the same values, you can move them outside the component. Now they don't need to be dependencies: + +```js +const serverUrl = "https://localhost:1234"; // serverUrl is not reactive +const roomId = "general"; // roomId is not reactive + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, []); // ✅ All dependencies declared + // ... +} +``` + +You can also move them _inside the Effect._ They aren't calculated during rendering, so they're not reactive: + +```js +function ChatRoom() { + useEffect(() => { + const serverUrl = "https://localhost:1234"; // serverUrl is not reactive + const roomId = "general"; // roomId is not reactive + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, []); // ✅ All dependencies declared + // ... +} +``` + +**Effects are reactive blocks of code.** They re-synchronize when the values you read inside of them change. Unlike event handlers, which only run once per interaction, Effects run whenever synchronization is necessary. + +**You can't "choose" your dependencies.** Your dependencies must include every [reactive value](#all-variables-declared-in-the-component-body-are-reactive) you read in the Effect. The linter enforces this. Sometimes this may lead to problems like infinite loops and to your Effect re-synchronizing too often. Don't fix these problems by suppressing the linter! Here's what to try instead: + +- **Check that your Effect represents an independent synchronization process.** If your Effect doesn't synchronize anything, [it might be unnecessary.](/learn/you-might-not-need-an-effect) If it synchronizes several independent things, [split it up.](#each-effect-represents-a-separate-synchronization-process) + +- **If you want to read the latest value of props or state without "reacting" to it and re-synchronizing the Effect,** you can split your Effect into a reactive part (which you'll keep in the Effect) and a non-reactive part (which you'll extract into something called an _Effect Event_). [Read about separating Events from Effects.](/learn/separating-events-from-effects) + +- **Avoid relying on objects and functions as dependencies.** If you create objects and functions during rendering and then read them from an Effect, they will be different on every render. This will cause your Effect to re-synchronize every time. [Read more about removing unnecessary dependencies from Effects.](/learn/removing-effect-dependencies) + +<Pitfall> + +The linter is your friend, but its powers are limited. The linter only knows when the dependencies are _wrong_. It doesn't know _the best_ way to solve each case. If the linter suggests a dependency, but adding it causes a loop, it doesn't mean the linter should be ignored. You need to change the code inside (or outside) the Effect so that that value isn't reactive and doesn't _need_ to be a dependency. + +If you have an existing codebase, you might have some Effects that suppress the linter like this: + +```js +useEffect(() => { + // ... + // 🔴 Avoid suppressing the linter like this: + // eslint-ignore-next-line react-hooks/exhaustive-deps +}, []); +``` + +On the [next](/learn/separating-events-from-effects) [pages](/learn/removing-effect-dependencies), you'll learn how to fix this code without breaking the rules. It's always worth fixing! + +</Pitfall> + +<Recap> + +- Components can mount, update, and unmount. +- Each Effect has a separate lifecycle from the surrounding component. +- Each Effect describes a separate synchronization process that can _start_ and _stop_. +- When you write and read Effects, think from each individual Effect's perspective (how to start and stop synchronization) rather than from the component's perspective (how it mounts, updates, or unmounts). +- Values declared inside the component body are "reactive". +- Reactive values should re-synchronize the Effect because they can change over time. +- The linter verifies that all reactive values used inside the Effect are specified as dependencies. +- All errors flagged by the linter are legitimate. There's always a way to fix the code to not break the rules. + +</Recap> + +<Challenges> + +#### Fix reconnecting on every keystroke + +In this example, the `ChatRoom` component connects to the chat room when the component mounts, disconnects when it unmounts, and reconnects when you select a different chat room. This behavior is correct, so you need to keep it working. + +However, there is a problem. Whenever you type into the message box input at the bottom, `ChatRoom` _also_ reconnects to the chat. (You can notice this by clearing the console and typing into the input.) Fix the issue so that this doesn't happen. + +<Hint> + +You might need to add a dependency array for this Effect. What dependencies should be there? + +</Hint> + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +<Solution> + +This Effect didn't have a dependency array at all, so it re-synchronized after every re-render. First, add a dependency array. Then, make sure that every reactive value used by the Effect is specified in the array. For example, `roomId` is reactive (because it's a prop), so it should be included in the array. This ensures that when the user selects a different room, the chat reconnects. On the other hand, `serverUrl` is defined outside the component. This is why it doesn't need to be in the array. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +</Solution> + +#### Switch synchronization on and off + +In this example, an Effect subscribes to the window [`pointermove`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointermove_event) event to move a pink dot on the screen. Try hovering over the preview area (or touching the screen if you're on a mobile device), and see how the pink dot follows your movement. + +There is also a checkbox. Ticking the checkbox toggles the `canMove` state variable, but this state variable is not used anywhere in the code. Your task is to change the code so that when `canMove` is `false` (the checkbox is ticked off), the dot should stop moving. After you toggle the checkbox back on (and set `canMove` to `true`), the box should follow the movement again. In other words, whether the dot can move or not should stay synchronized to whether the checkbox is checked. + +<Hint> + +You can't declare an Effect conditionally. However, the code inside the Effect can use conditions! + +</Hint> + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + }, []); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +<Solution> + +One solution is to wrap the `setPosition` call into an `if (canMove) { ... }` condition: + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + }, [canMove]); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +Alternatively, you could wrap the _event subscription_ logic into an `if (canMove) { ... }` condition: + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + if (canMove) { + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + } + }, [canMove]); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +In both of these cases, `canMove` is a reactive variable that you read inside the Effect. This is why it must be specified in the list of Effect dependencies. This ensures that the Effect re-synchronizes after every change to its value. + +</Solution> + +#### Investigate a stale value bug + +In this example, the pink dot should move when the checkbox is on, and should stop moving when the checkbox is off. The logic for this has already been implemented: the `handleMove` event handler checks the `canMove` state variable. + +However, for some reason, the `canMove` state variable inside `handleMove` appears to be "stale": it's always `true`, even after you tick off the checkbox. How is this possible? Find the mistake in the code and fix it. + +<Hint> + +If you see a linter rule being suppressed, remove the suppression! That's where the mistakes usually are. + +</Hint> + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + useEffect(() => { + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +<Solution> + +The problem with the original code was suppressing the dependency linter. If you remove the suppression, you'll see that this Effect depends on the `handleMove` function. This makes sense: `handleMove` is declared inside the component body, which makes it a reactive value. Every reactive value must be specified as a dependency, or it can potentially get stale over time! + +The author of the original code has "lied" to React by saying that the Effect does not depend (`[]`) on any reactive values. This is why React did not re-synchronize the Effect after `canMove` has changed (and `handleMove` with it). Because React did not re-synchronize the Effect, the `handleMove` attached as a listener is the `handleMove` function created during the initial render. During the initial render, `canMove` was `true`, which is why `handleMove` from the initial render will forever see that value. + +**If you never suppress the linter, you will never see problems with stale values.** There are a few different ways to solve this bug, but you should always start by removing the linter suppression. Then change the code to fix the lint error. + +You can change the Effect dependencies to `[handleMove]`, but since it's going to be a newly defined function for every render, you might as well remove dependencies array altogether. Then the Effect _will_ re-synchronize after every re-render: + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + useEffect(() => { + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + }); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +This solution works, but it's not ideal. If you put `console.log('Resubscribing')` inside the Effect, you'll notice that it resubscribes after every re-render. Resubscribing is fast, but it would still be nice to avoid doing it so often. + +A better fix would be to move the `handleMove` function _inside_ the Effect. Then `handleMove` won't be a reactive value, and so your Effect won't depend on a function. Instead, it will need to depend on `canMove` which your code now reads from inside the Effect. This matches the behavior you wanted, since your Effect will now stay synchronized with the value of `canMove`: + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + }, [canMove]); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +Try adding `console.log('Resubscribing')` inside the Effect body and notice that now it only resubscribes when you toggle the checkbox (`canMove` changes) or edit the code. This makes it better than the previous approach that always resubscribed. + +You'll learn a more general approach to this type of problem in [Separating Events from Effects.](/learn/separating-events-from-effects) + +</Solution> + +#### Fix a connection switch + +In this example, the chat service in `chat.js` exposes two different APIs: `createEncryptedConnection` and `createUnencryptedConnection`. The root `App` component lets the user choose whether to use encryption or not, and then passes down the corresponding API method to the child `ChatRoom` component as the `createConnection` prop. + +Notice that initially, the console logs say the connection is not encrypted. Try toggling the checkbox on: nothing will happen. However, if you change the selected room after that, then the chat will reconnect _and_ enable encryption (as you'll see from the console messages). This is a bug. Fix the bug so that toggling the checkbox _also_ causes the chat to reconnect. + +<Hint> + +Suppressing the linter is always suspicious. Could this be a bug? + +</Hint> + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from "./chat.js"; + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isEncrypted, setIsEncrypted] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isEncrypted} + onChange={(e) => setIsEncrypted(e.target.checked)} + /> + Enable encryption + </label> + <hr /> + <ChatRoom + roomId={roomId} + createConnection={ + isEncrypted + ? createEncryptedConnection + : createUnencryptedConnection + } + /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export default function ChatRoom({ roomId, createConnection }) { + useEffect(() => { + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId]); + + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createEncryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ 🔐 Connecting to "' + roomId + "... (encrypted)"); + }, + disconnect() { + console.log( + '❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)' + ); + }, + }; +} + +export function createUnencryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + "... (unencrypted)"); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room (unencrypted)' + ); + }, + }; +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +``` + +<Solution> + +If you remove the linter suppression, you will see a lint error. The problem is that `createConnection` is a prop, so it's a reactive value. It can change over time! (And indeed, it should--when the user ticks the checkbox, the parent component passes a different value of the `createConnection` prop.) This is why it should be a dependency. Include it in the list to fix the bug: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from "./chat.js"; + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isEncrypted, setIsEncrypted] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isEncrypted} + onChange={(e) => setIsEncrypted(e.target.checked)} + /> + Enable encryption + </label> + <hr /> + <ChatRoom + roomId={roomId} + createConnection={ + isEncrypted + ? createEncryptedConnection + : createUnencryptedConnection + } + /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export default function ChatRoom({ roomId, createConnection }) { + useEffect(() => { + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, createConnection]); + + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createEncryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ 🔐 Connecting to "' + roomId + "... (encrypted)"); + }, + disconnect() { + console.log( + '❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)' + ); + }, + }; +} + +export function createUnencryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + "... (unencrypted)"); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room (unencrypted)' + ); + }, + }; +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +``` + +It is correct that `createConnection` is a dependency. However, this code is a bit fragile because someone could edit the `App` component to pass an inline function as the value of this prop. In that case, its value would be different every time the `App` component re-renders, so the Effect might re-synchronize too often. To avoid this, you can pass `isEncrypted` down instead: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isEncrypted, setIsEncrypted] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isEncrypted} + onChange={(e) => setIsEncrypted(e.target.checked)} + /> + Enable encryption + </label> + <hr /> + <ChatRoom roomId={roomId} isEncrypted={isEncrypted} /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from "./chat.js"; + +export default function ChatRoom({ roomId, isEncrypted }) { + useEffect(() => { + const createConnection = isEncrypted + ? createEncryptedConnection + : createUnencryptedConnection; + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, isEncrypted]); + + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createEncryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ 🔐 Connecting to "' + roomId + "... (encrypted)"); + }, + disconnect() { + console.log( + '❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)' + ); + }, + }; +} + +export function createUnencryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + "... (unencrypted)"); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room (unencrypted)' + ); + }, + }; +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +``` + +In this version, the `App` component passes a boolean prop instead of a function. Inside the Effect, you decide which function to use. Since both `createEncryptedConnection` and `createUnencryptedConnection` are declared outside the component, they aren't reactive, and don't need to be dependencies. You'll learn more about this in [Removing Effect Dependencies.](/learn/removing-effect-dependencies) + +</Solution> + +#### Populate a chain of select boxes + +In this example, there are two select boxes. One select box lets the user pick a planet. Another select box lets the user pick a place _on that planet._ The second box doesn't work yet. Your task is to make it show the places on the chosen planet. + +Look at how the first select box works. It populates the `planetList` state with the result from the `"/planets"` API call. The currently selected planet's ID is kept in the `planetId` state variable. You need to find where to add some additional code so that the `placeList` state variable is populated with the result of the `"/planets/" + planetId + "/places"` API call. + +If you implement this right, selecting a planet should populate the place list. Changing a planet should change the place list. + +<Hint> + +If you have two independent synchronization processes, you need to write two separate Effects. + +</Hint> + +```js +import { useState, useEffect } from "react"; +import { fetchData } from "./api.js"; + +export default function Page() { + const [planetList, setPlanetList] = useState([]); + const [planetId, setPlanetId] = useState(""); + + const [placeList, setPlaceList] = useState([]); + const [placeId, setPlaceId] = useState(""); + + useEffect(() => { + let ignore = false; + fetchData("/planets").then((result) => { + if (!ignore) { + console.log("Fetched a list of planets."); + setPlanetList(result); + setPlanetId(result[0].id); // Select the first planet + } + }); + return () => { + ignore = true; + }; + }, []); + + return ( + <> + <label> + Pick a planet:{" "} + <select + value={planetId} + onChange={(e) => { + setPlanetId(e.target.value); + }} + > + {planetList.map((planet) => ( + <option key={planet.id} value={planet.id}> + {planet.name} + </option> + ))} + </select> + </label> + <label> + Pick a place:{" "} + <select + value={placeId} + onChange={(e) => { + setPlaceId(e.target.value); + }} + > + {placeList.map((place) => ( + <option key={place.id} value={place.id}> + {place.name} + </option> + ))} + </select> + </label> + <hr /> + <p> + You are going to: {placeId || "???"} on {planetId || "???"}{" "} + </p> + </> + ); +} +``` + +```js +export function fetchData(url) { + if (url === "/planets") { + return fetchPlanets(); + } else if (url.startsWith("/planets/")) { + const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/); + if (!match || !match[1] || !match[1].length) { + throw Error( + 'Expected URL like "/planets/earth/places". Received: "' + + url + + '".' + ); + } + return fetchPlaces(match[1]); + } else + throw Error( + 'Expected URL like "/planets" or "/planets/earth/places". Received: "' + + url + + '".' + ); +} + +async function fetchPlanets() { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "earth", + name: "Earth", + }, + { + id: "venus", + name: "Venus", + }, + { + id: "mars", + name: "Mars", + }, + ]); + }, 1000); + }); +} + +async function fetchPlaces(planetId) { + if (typeof planetId !== "string") { + throw Error( + "fetchPlaces(planetId) expects a string argument. " + + "Instead received: " + + planetId + + "." + ); + } + return new Promise((resolve) => { + setTimeout(() => { + if (planetId === "earth") { + resolve([ + { + id: "laos", + name: "Laos", + }, + { + id: "spain", + name: "Spain", + }, + { + id: "vietnam", + name: "Vietnam", + }, + ]); + } else if (planetId === "venus") { + resolve([ + { + id: "aurelia", + name: "Aurelia", + }, + { + id: "diana-chasma", + name: "Diana Chasma", + }, + { + id: "kumsong-vallis", + name: "Kŭmsŏng Vallis", + }, + ]); + } else if (planetId === "mars") { + resolve([ + { + id: "aluminum-city", + name: "Aluminum City", + }, + { + id: "new-new-york", + name: "New New York", + }, + { + id: "vishniac", + name: "Vishniac", + }, + ]); + } else throw Error("Unknown planet ID: " + planetId); + }, 1000); + }); +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +``` + +<Solution> + +There are two independent synchronization processes: + +- The first select box is synchronized to the remote list of planets. +- The second select box is synchronized to the remote list of places for the current `planetId`. + +This is why it makes sense to describe them as two separate Effects. Here's an example of how you could do this: + +```js +import { useState, useEffect } from "react"; +import { fetchData } from "./api.js"; + +export default function Page() { + const [planetList, setPlanetList] = useState([]); + const [planetId, setPlanetId] = useState(""); + + const [placeList, setPlaceList] = useState([]); + const [placeId, setPlaceId] = useState(""); + + useEffect(() => { + let ignore = false; + fetchData("/planets").then((result) => { + if (!ignore) { + console.log("Fetched a list of planets."); + setPlanetList(result); + setPlanetId(result[0].id); // Select the first planet + } + }); + return () => { + ignore = true; + }; + }, []); + + useEffect(() => { + if (planetId === "") { + // Nothing is selected in the first box yet + return; + } + + let ignore = false; + fetchData("/planets/" + planetId + "/places").then((result) => { + if (!ignore) { + console.log('Fetched a list of places on "' + planetId + '".'); + setPlaceList(result); + setPlaceId(result[0].id); // Select the first place + } + }); + return () => { + ignore = true; + }; + }, [planetId]); + + return ( + <> + <label> + Pick a planet:{" "} + <select + value={planetId} + onChange={(e) => { + setPlanetId(e.target.value); + }} + > + {planetList.map((planet) => ( + <option key={planet.id} value={planet.id}> + {planet.name} + </option> + ))} + </select> + </label> + <label> + Pick a place:{" "} + <select + value={placeId} + onChange={(e) => { + setPlaceId(e.target.value); + }} + > + {placeList.map((place) => ( + <option key={place.id} value={place.id}> + {place.name} + </option> + ))} + </select> + </label> + <hr /> + <p> + You are going to: {placeId || "???"} on {planetId || "???"}{" "} + </p> + </> + ); +} +``` + +```js +export function fetchData(url) { + if (url === "/planets") { + return fetchPlanets(); + } else if (url.startsWith("/planets/")) { + const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/); + if (!match || !match[1] || !match[1].length) { + throw Error( + 'Expected URL like "/planets/earth/places". Received: "' + + url + + '".' + ); + } + return fetchPlaces(match[1]); + } else + throw Error( + 'Expected URL like "/planets" or "/planets/earth/places". Received: "' + + url + + '".' + ); +} + +async function fetchPlanets() { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "earth", + name: "Earth", + }, + { + id: "venus", + name: "Venus", + }, + { + id: "mars", + name: "Mars", + }, + ]); + }, 1000); + }); +} + +async function fetchPlaces(planetId) { + if (typeof planetId !== "string") { + throw Error( + "fetchPlaces(planetId) expects a string argument. " + + "Instead received: " + + planetId + + "." + ); + } + return new Promise((resolve) => { + setTimeout(() => { + if (planetId === "earth") { + resolve([ + { + id: "laos", + name: "Laos", + }, + { + id: "spain", + name: "Spain", + }, + { + id: "vietnam", + name: "Vietnam", + }, + ]); + } else if (planetId === "venus") { + resolve([ + { + id: "aurelia", + name: "Aurelia", + }, + { + id: "diana-chasma", + name: "Diana Chasma", + }, + { + id: "kumsong-vallis", + name: "Kŭmsŏng Vallis", + }, + ]); + } else if (planetId === "mars") { + resolve([ + { + id: "aluminum-city", + name: "Aluminum City", + }, + { + id: "new-new-york", + name: "New New York", + }, + { + id: "vishniac", + name: "Vishniac", + }, + ]); + } else throw Error("Unknown planet ID: " + planetId); + }, 1000); + }); +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +``` + +This code is a bit repetitive. However, that's not a good reason to combine it into a single Effect! If you did this, you'd have to combine both Effect's dependencies into one list, and then changing the planet would refetch the list of all planets. Effects are not a tool for code reuse. + +Instead, to reduce repetition, you can extract some logic into a custom Hook like `useSelectOptions` below: + +```js +import { useState } from "react"; +import { useSelectOptions } from "./useSelectOptions.js"; + +export default function Page() { + const [planetList, planetId, setPlanetId] = useSelectOptions("/planets"); + + const [placeList, placeId, setPlaceId] = useSelectOptions( + planetId ? `/planets/${planetId}/places` : null + ); + + return ( + <> + <label> + Pick a planet:{" "} + <select + value={planetId} + onChange={(e) => { + setPlanetId(e.target.value); + }} + > + {planetList?.map((planet) => ( + <option key={planet.id} value={planet.id}> + {planet.name} + </option> + ))} + </select> + </label> + <label> + Pick a place:{" "} + <select + value={placeId} + onChange={(e) => { + setPlaceId(e.target.value); + }} + > + {placeList?.map((place) => ( + <option key={place.id} value={place.id}> + {place.name} + </option> + ))} + </select> + </label> + <hr /> + <p> + You are going to: {placeId || "..."} on {planetId || "..."}{" "} + </p> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { fetchData } from "./api.js"; + +export function useSelectOptions(url) { + const [list, setList] = useState(null); + const [selectedId, setSelectedId] = useState(""); + useEffect(() => { + if (url === null) { + return; + } + + let ignore = false; + fetchData(url).then((result) => { + if (!ignore) { + setList(result); + setSelectedId(result[0].id); + } + }); + return () => { + ignore = true; + }; + }, [url]); + return [list, selectedId, setSelectedId]; +} +``` + +```js +export function fetchData(url) { + if (url === "/planets") { + return fetchPlanets(); + } else if (url.startsWith("/planets/")) { + const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/); + if (!match || !match[1] || !match[1].length) { + throw Error( + 'Expected URL like "/planets/earth/places". Received: "' + + url + + '".' + ); + } + return fetchPlaces(match[1]); + } else + throw Error( + 'Expected URL like "/planets" or "/planets/earth/places". Received: "' + + url + + '".' + ); +} + +async function fetchPlanets() { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "earth", + name: "Earth", + }, + { + id: "venus", + name: "Venus", + }, + { + id: "mars", + name: "Mars", + }, + ]); + }, 1000); + }); +} + +async function fetchPlaces(planetId) { + if (typeof planetId !== "string") { + throw Error( + "fetchPlaces(planetId) expects a string argument. " + + "Instead received: " + + planetId + + "." + ); + } + return new Promise((resolve) => { + setTimeout(() => { + if (planetId === "earth") { + resolve([ + { + id: "laos", + name: "Laos", + }, + { + id: "spain", + name: "Spain", + }, + { + id: "vietnam", + name: "Vietnam", + }, + ]); + } else if (planetId === "venus") { + resolve([ + { + id: "aurelia", + name: "Aurelia", + }, + { + id: "diana-chasma", + name: "Diana Chasma", + }, + { + id: "kumsong-vallis", + name: "Kŭmsŏng Vallis", + }, + ]); + } else if (planetId === "mars") { + resolve([ + { + id: "aluminum-city", + name: "Aluminum City", + }, + { + id: "new-new-york", + name: "New New York", + }, + { + id: "vishniac", + name: "Vishniac", + }, + ]); + } else throw Error("Unknown planet ID: " + planetId); + }, 1000); + }); +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +``` + +Check the `useSelectOptions.js` tab in the sandbox to see how it works. Ideally, most Effects in your application should eventually be replaced by custom Hooks, whether written by you or by the community. Custom Hooks hide the synchronization logic, so the calling component doesn't know about the Effect. As you keep working on your app, you'll develop a palette of Hooks to choose from, and eventually you won't need to write Effects in your components very often. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/manipulating-the-dom-with-refs.md b/docs/src/learn/manipulating-the-dom-with-refs.md new file mode 100644 index 000000000..a836cae1e --- /dev/null +++ b/docs/src/learn/manipulating-the-dom-with-refs.md @@ -0,0 +1,1074 @@ +## Overview + +<p class="intro" markdown> + +React automatically updates the [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model/Introduction) to match your render output, so your components won't often need to manipulate it. However, sometimes you might need access to the DOM elements managed by React--for example, to focus a node, scroll to it, or measure its size and position. There is no built-in way to do those things in React, so you will need a _ref_ to the DOM node. + +</p> + +!!! summary "You will learn" + + - How to access a DOM node managed by React with the `ref` attribute + - How the `ref` JSX attribute relates to the `useRef` Hook + - How to access another component's DOM node + - In which cases it's safe to modify the DOM managed by React + +## Getting a ref to the node + +To access a DOM node managed by React, first, import the `useRef` Hook: + +```js +import { useRef } from "react"; +``` + +Then, use it to declare a ref inside your component: + +```js +const myRef = useRef(null); +``` + +Finally, pass your ref as the `ref` attribute to the JSX tag for which you want to get the DOM node: + +```js +<div ref={myRef}> +``` + +The `useRef` Hook returns an object with a single property called `current`. Initially, `myRef.current` will be `null`. When React creates a DOM node for this `<div>`, React will put a reference to this node into `myRef.current`. You can then access this DOM node from your [event handlers](/learn/responding-to-events) and use the built-in [browser APIs](https://developer.mozilla.org/docs/Web/API/Element) defined on it. + +```js +// You can use any browser APIs, for example: +myRef.current.scrollIntoView(); +``` + +### Example: Focusing a text input + +In this example, clicking the button will focus the input: + +```js +import { useRef } from "react"; + +export default function Form() { + const inputRef = useRef(null); + + function handleClick() { + inputRef.current.focus(); + } + + return ( + <> + <input ref={inputRef} /> + <button on_click={handleClick}>Focus the input</button> + </> + ); +} +``` + +To implement this: + +1. Declare `inputRef` with the `useRef` Hook. +2. Pass it as `<input ref={inputRef}>`. This tells React to **put this `<input>`'s DOM node into `inputRef.current`.** +3. In the `handleClick` function, read the input DOM node from `inputRef.current` and call [`focus()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) on it with `inputRef.current.focus()`. +4. Pass the `handleClick` event handler to `<button>` with `on_click`. + +While DOM manipulation is the most common use case for refs, the `useRef` Hook can be used for storing other things outside React, like timer IDs. Similarly to state, refs remain between renders. Refs are like state variables that don't trigger re-renders when you set them. Read about refs in [Referencing Values with Refs.](/learn/referencing-values-with-refs) + +### Example: Scrolling to an element + +You can have more than a single ref in a component. In this example, there is a carousel of three images. Each button centers an image by calling the browser [`scrollIntoView()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) method on the corresponding DOM node: + +```js +import { useRef } from "react"; + +export default function CatFriends() { + const firstCatRef = useRef(null); + const secondCatRef = useRef(null); + const thirdCatRef = useRef(null); + + function handleScrollToFirstCat() { + firstCatRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + + function handleScrollToSecondCat() { + secondCatRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + + function handleScrollToThirdCat() { + thirdCatRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + + return ( + <> + <nav> + <button on_click={handleScrollToFirstCat}>Tom</button> + <button on_click={handleScrollToSecondCat}>Maru</button> + <button on_click={handleScrollToThirdCat}>Jellylorum</button> + </nav> + <div> + <ul> + <li> + <img + src="https://placekitten.com/g/200/200" + alt="Tom" + ref={firstCatRef} + /> + </li> + <li> + <img + src="https://placekitten.com/g/300/200" + alt="Maru" + ref={secondCatRef} + /> + </li> + <li> + <img + src="https://placekitten.com/g/250/200" + alt="Jellylorum" + ref={thirdCatRef} + /> + </li> + </ul> + </div> + </> + ); +} +``` + +```css +div { + width: 100%; + overflow: hidden; +} + +nav { + text-align: center; +} + +button { + margin: 0.25rem; +} + +ul, +li { + list-style: none; + white-space: nowrap; +} + +li { + display: inline; + padding: 0.5rem; +} +``` + +<DeepDive> + +#### How to manage a list of refs using a ref callback + +In the above examples, there is a predefined number of refs. However, sometimes you might need a ref to each item in the list, and you don't know how many you will have. Something like this **wouldn't work**: + +```js +<ul> + {items.map((item) => { + // Doesn't work! + const ref = useRef(null); + return <li ref={ref} />; + })} +</ul> +``` + +This is because **Hooks must only be called at the top-level of your component.** You can't call `useRef` in a loop, in a condition, or inside a `map()` call. + +One possible way around this is to get a single ref to their parent element, and then use DOM manipulation methods like [`querySelectorAll`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll) to "find" the individual child nodes from it. However, this is brittle and can break if your DOM structure changes. + +Another solution is to **pass a function to the `ref` attribute.** This is called a [`ref` callback.](/reference/react-dom/components/common#ref-callback) React will call your ref callback with the DOM node when it's time to set the ref, and with `null` when it's time to clear it. This lets you maintain your own array or a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), and access any ref by its index or some kind of ID. + +This example shows how you can use this approach to scroll to an arbitrary node in a long list: + +```js +import { useRef } from "react"; + +export default function CatFriends() { + const itemsRef = useRef(null); + + function scrollToId(itemId) { + const map = getMap(); + const node = map.get(itemId); + node.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + + function getMap() { + if (!itemsRef.current) { + // Initialize the Map on first usage. + itemsRef.current = new Map(); + } + return itemsRef.current; + } + + return ( + <> + <nav> + <button on_click={() => scrollToId(0)}>Tom</button> + <button on_click={() => scrollToId(5)}>Maru</button> + <button on_click={() => scrollToId(9)}>Jellylorum</button> + </nav> + <div> + <ul> + {catList.map((cat) => ( + <li + key={cat.id} + ref={(node) => { + const map = getMap(); + if (node) { + map.set(cat.id, node); + } else { + map.delete(cat.id); + } + }} + > + <img src={cat.imageUrl} alt={"Cat #" + cat.id} /> + </li> + ))} + </ul> + </div> + </> + ); +} + +const catList = []; +for (let i = 0; i < 10; i++) { + catList.push({ + id: i, + imageUrl: "https://placekitten.com/250/200?image=" + i, + }); +} +``` + +```css +div { + width: 100%; + overflow: hidden; +} + +nav { + text-align: center; +} + +button { + margin: 0.25rem; +} + +ul, +li { + list-style: none; + white-space: nowrap; +} + +li { + display: inline; + padding: 0.5rem; +} +``` + +In this example, `itemsRef` doesn't hold a single DOM node. Instead, it holds a [Map](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map) from item ID to a DOM node. ([Refs can hold any values!](/learn/referencing-values-with-refs)) The [`ref` callback](/reference/react-dom/components/common#ref-callback) on every list item takes care to update the Map: + +```js +<li + key={cat.id} + ref={node => { + const map = getMap(); + if (node) { + // Add to the Map + map.set(cat.id, node); + } else { + // Remove from the Map + map.delete(cat.id); + } + }} +> +``` + +This lets you read individual DOM nodes from the Map later. + +</DeepDive> + +## Accessing another component's DOM nodes + +When you put a ref on a built-in component that outputs a browser element like `<input />`, React will set that ref's `current` property to the corresponding DOM node (such as the actual `<input />` in the browser). + +However, if you try to put a ref on **your own** component, like `<MyInput />`, by default you will get `null`. Here is an example demonstrating it. Notice how clicking the button **does not** focus the input: + +```js +import { useRef } from "react"; + +function MyInput(props) { + return <input {...props} />; +} + +export default function MyForm() { + const inputRef = useRef(null); + + function handleClick() { + inputRef.current.focus(); + } + + return ( + <> + <MyInput ref={inputRef} /> + <button on_click={handleClick}>Focus the input</button> + </> + ); +} +``` + +To help you notice the issue, React also prints an error to the console: + +<ConsoleBlock level="error"> + +Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? + +</ConsoleBlock> + +This happens because by default React does not let a component access the DOM nodes of other components. Not even for its own children! This is intentional. Refs are an escape hatch that should be used sparingly. Manually manipulating _another_ component's DOM nodes makes your code even more fragile. + +Instead, components that _want_ to expose their DOM nodes have to **opt in** to that behavior. A component can specify that it "forwards" its ref to one of its children. Here's how `MyInput` can use the `forwardRef` API: + +```js +const MyInput = forwardRef((props, ref) => { + return <input {...props} ref={ref} />; +}); +``` + +This is how it works: + +1. `<MyInput ref={inputRef} />` tells React to put the corresponding DOM node into `inputRef.current`. However, it's up to the `MyInput` component to opt into that--by default, it doesn't. +2. The `MyInput` component is declared using `forwardRef`. **This opts it into receiving the `inputRef` from above as the second `ref` argument** which is declared after `props`. +3. `MyInput` itself passes the `ref` it received to the `<input>` inside of it. + +Now clicking the button to focus the input works: + +```js +import { forwardRef, useRef } from "react"; + +const MyInput = forwardRef((props, ref) => { + return <input {...props} ref={ref} />; +}); + +export default function Form() { + const inputRef = useRef(null); + + function handleClick() { + inputRef.current.focus(); + } + + return ( + <> + <MyInput ref={inputRef} /> + <button on_click={handleClick}>Focus the input</button> + </> + ); +} +``` + +In design systems, it is a common pattern for low-level components like buttons, inputs, and so on, to forward their refs to their DOM nodes. On the other hand, high-level components like forms, lists, or page sections usually won't expose their DOM nodes to avoid accidental dependencies on the DOM structure. + +<DeepDive> + +#### Exposing a subset of the API with an imperative handle + +In the above example, `MyInput` exposes the original DOM input element. This lets the parent component call `focus()` on it. However, this also lets the parent component do something else--for example, change its CSS styles. In uncommon cases, you may want to restrict the exposed functionality. You can do that with `useImperativeHandle`: + +```js +import { forwardRef, useRef, useImperativeHandle } from "react"; + +const MyInput = forwardRef((props, ref) => { + const realInputRef = useRef(null); + useImperativeHandle(ref, () => ({ + // Only expose focus and nothing else + focus() { + realInputRef.current.focus(); + }, + })); + return <input {...props} ref={realInputRef} />; +}); + +export default function Form() { + const inputRef = useRef(null); + + function handleClick() { + inputRef.current.focus(); + } + + return ( + <> + <MyInput ref={inputRef} /> + <button on_click={handleClick}>Focus the input</button> + </> + ); +} +``` + +Here, `realInputRef` inside `MyInput` holds the actual input DOM node. However, `useImperativeHandle` instructs React to provide your own special object as the value of a ref to the parent component. So `inputRef.current` inside the `Form` component will only have the `focus` method. In this case, the ref "handle" is not the DOM node, but the custom object you create inside `useImperativeHandle` call. + +</DeepDive> + +## When React attaches the refs + +In React, every update is split in [two phases](/learn/render-and-commit#step-3-react-commits-changes-to-the-dom): + +- During **render,** React calls your components to figure out what should be on the screen. +- During **commit,** React applies changes to the DOM. + +In general, you [don't want](/learn/referencing-values-with-refs#best-practices-for-refs) to access refs during rendering. That goes for refs holding DOM nodes as well. During the first render, the DOM nodes have not yet been created, so `ref.current` will be `null`. And during the rendering of updates, the DOM nodes haven't been updated yet. So it's too early to read them. + +React sets `ref.current` during the commit. Before updating the DOM, React sets the affected `ref.current` values to `null`. After updating the DOM, React immediately sets them to the corresponding DOM nodes. + +**Usually, you will access refs from event handlers.** If you want to do something with a ref, but there is no particular event to do it in, you might need an Effect. We will discuss effects on the next pages. + +<DeepDive> + +#### Flushing state updates synchronously with flushSync + +Consider code like this, which adds a new todo and scrolls the screen down to the last child of the list. Notice how, for some reason, it always scrolls to the todo that was _just before_ the last added one: + +```js +import { useState, useRef } from "react"; + +export default function TodoList() { + const listRef = useRef(null); + const [text, setText] = useState(""); + const [todos, setTodos] = useState(initialTodos); + + function handleAdd() { + const newTodo = { id: nextId++, text: text }; + setText(""); + setTodos([...todos, newTodo]); + listRef.current.lastChild.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + + return ( + <> + <button on_click={handleAdd}>Add</button> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <ul ref={listRef}> + {todos.map((todo) => ( + <li key={todo.id}>{todo.text}</li> + ))} + </ul> + </> + ); +} + +let nextId = 0; +let initialTodos = []; +for (let i = 0; i < 20; i++) { + initialTodos.push({ + id: nextId++, + text: "Todo #" + (i + 1), + }); +} +``` + +The issue is with these two lines: + +```js +setTodos([...todos, newTodo]); +listRef.current.lastChild.scrollIntoView(); +``` + +In React, [state updates are queued.](/learn/queueing-a-series-of-state-updates) Usually, this is what you want. However, here it causes a problem because `setTodos` does not immediately update the DOM. So the time you scroll the list to its last element, the todo has not yet been added. This is why scrolling always "lags behind" by one item. + +To fix this issue, you can force React to update ("flush") the DOM synchronously. To do this, import `flushSync` from `react-dom` and **wrap the state update** into a `flushSync` call: + +```js +flushSync(() => { + setTodos([...todos, newTodo]); +}); +listRef.current.lastChild.scrollIntoView(); +``` + +This will instruct React to update the DOM synchronously right after the code wrapped in `flushSync` executes. As a result, the last todo will already be in the DOM by the time you try to scroll to it: + +```js +import { useState, useRef } from "react"; +import { flushSync } from "react-dom"; + +export default function TodoList() { + const listRef = useRef(null); + const [text, setText] = useState(""); + const [todos, setTodos] = useState(initialTodos); + + function handleAdd() { + const newTodo = { id: nextId++, text: text }; + flushSync(() => { + setText(""); + setTodos([...todos, newTodo]); + }); + listRef.current.lastChild.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + + return ( + <> + <button on_click={handleAdd}>Add</button> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <ul ref={listRef}> + {todos.map((todo) => ( + <li key={todo.id}>{todo.text}</li> + ))} + </ul> + </> + ); +} + +let nextId = 0; +let initialTodos = []; +for (let i = 0; i < 20; i++) { + initialTodos.push({ + id: nextId++, + text: "Todo #" + (i + 1), + }); +} +``` + +</DeepDive> + +## Best practices for DOM manipulation with refs + +Refs are an escape hatch. You should only use them when you have to "step outside React". Common examples of this include managing focus, scroll position, or calling browser APIs that React does not expose. + +If you stick to non-destructive actions like focusing and scrolling, you shouldn't encounter any problems. However, if you try to **modify** the DOM manually, you can risk conflicting with the changes React is making. + +To illustrate this problem, this example includes a welcome message and two buttons. The first button toggles its presence using [conditional rendering](/learn/conditional-rendering) and [state](/learn/state-a-components-memory), as you would usually do in React. The second button uses the [`remove()` DOM API](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) to forcefully remove it from the DOM outside of React's control. + +Try pressing "Toggle with setState" a few times. The message should disappear and appear again. Then press "Remove from the DOM". This will forcefully remove it. Finally, press "Toggle with setState": + +```js +import { useState, useRef } from "react"; + +export default function Counter() { + const [show, setShow] = useState(true); + const ref = useRef(null); + + return ( + <div> + <button + on_click={() => { + setShow(!show); + }} + > + Toggle with setState + </button> + <button + on_click={() => { + ref.current.remove(); + }} + > + Remove from the DOM + </button> + {show && <p ref={ref}>Hello world</p>} + </div> + ); +} +``` + +```css +p, +button { + display: block; + margin: 10px; +} +``` + +After you've manually removed the DOM element, trying to use `setState` to show it again will lead to a crash. This is because you've changed the DOM, and React doesn't know how to continue managing it correctly. + +**Avoid changing DOM nodes managed by React.** Modifying, adding children to, or removing children from elements that are managed by React can lead to inconsistent visual results or crashes like above. + +However, this doesn't mean that you can't do it at all. It requires caution. **You can safely modify parts of the DOM that React has _no reason_ to update.** For example, if some `<div>` is always empty in the JSX, React won't have a reason to touch its children list. Therefore, it is safe to manually add or remove elements there. + +<Recap> + +- Refs are a generic concept, but most often you'll use them to hold DOM elements. +- You instruct React to put a DOM node into `myRef.current` by passing `<div ref={myRef}>`. +- Usually, you will use refs for non-destructive actions like focusing, scrolling, or measuring DOM elements. +- A component doesn't expose its DOM nodes by default. You can opt into exposing a DOM node by using `forwardRef` and passing the second `ref` argument down to a specific node. +- Avoid changing DOM nodes managed by React. +- If you do modify DOM nodes managed by React, modify parts that React has no reason to update. + +</Recap> + +<Challenges> + +#### Play and pause the video + +In this example, the button toggles a state variable to switch between a playing and a paused state. However, in order to actually play or pause the video, toggling state is not enough. You also need to call [`play()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play) and [`pause()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause) on the DOM element for the `<video>`. Add a ref to it, and make the button work. + +```js +import { useState, useRef } from "react"; + +export default function VideoPlayer() { + const [isPlaying, setIsPlaying] = useState(false); + + function handleClick() { + const nextIsPlaying = !isPlaying; + setIsPlaying(nextIsPlaying); + } + + return ( + <> + <button on_click={handleClick}> + {isPlaying ? "Pause" : "Play"} + </button> + <video width="250"> + <source + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + type="video/mp4" + /> + </video> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 20px; +} +``` + +For an extra challenge, keep the "Play" button in sync with whether the video is playing even if the user right-clicks the video and plays it using the built-in browser media controls. You might want to listen to `onPlay` and `onPause` on the video to do that. + +<Solution> + +Declare a ref and put it on the `<video>` element. Then call `ref.current.play()` and `ref.current.pause()` in the event handler depending on the next state. + +```js +import { useState, useRef } from "react"; + +export default function VideoPlayer() { + const [isPlaying, setIsPlaying] = useState(false); + const ref = useRef(null); + + function handleClick() { + const nextIsPlaying = !isPlaying; + setIsPlaying(nextIsPlaying); + + if (nextIsPlaying) { + ref.current.play(); + } else { + ref.current.pause(); + } + } + + return ( + <> + <button on_click={handleClick}> + {isPlaying ? "Pause" : "Play"} + </button> + <video + width="250" + ref={ref} + onPlay={() => setIsPlaying(true)} + onPause={() => setIsPlaying(false)} + > + <source + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + type="video/mp4" + /> + </video> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 20px; +} +``` + +In order to handle the built-in browser controls, you can add `onPlay` and `onPause` handlers to the `<video>` element and call `setIsPlaying` from them. This way, if the user plays the video using the browser controls, the state will adjust accordingly. + +</Solution> + +#### Focus the search field + +Make it so that clicking the "Search" button puts focus into the field. + +```js +export default function Page() { + return ( + <> + <nav> + <button>Search</button> + </nav> + <input placeholder="Looking for something?" /> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +``` + +<Solution> + +Add a ref to the input, and call `focus()` on the DOM node to focus it: + +```js +import { useRef } from "react"; + +export default function Page() { + const inputRef = useRef(null); + return ( + <> + <nav> + <button + on_click={() => { + inputRef.current.focus(); + }} + > + Search + </button> + </nav> + <input ref={inputRef} placeholder="Looking for something?" /> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +``` + +</Solution> + +#### Scrolling an image carousel + +This image carousel has a "Next" button that switches the active image. Make the gallery scroll horizontally to the active image on click. You will want to call [`scrollIntoView()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) on the DOM node of the active image: + +```js +node.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", +}); +``` + +<Hint> + +You don't need to have a ref to every image for this exercise. It should be enough to have a ref to the currently active image, or to the list itself. Use `flushSync` to ensure the DOM is updated _before_ you scroll. + +</Hint> + +```js +import { useState } from "react"; + +export default function CatFriends() { + const [index, setIndex] = useState(0); + return ( + <> + <nav> + <button + on_click={() => { + if (index < catList.length - 1) { + setIndex(index + 1); + } else { + setIndex(0); + } + }} + > + Next + </button> + </nav> + <div> + <ul> + {catList.map((cat, i) => ( + <li key={cat.id}> + <img + className={index === i ? "active" : ""} + src={cat.imageUrl} + alt={"Cat #" + cat.id} + /> + </li> + ))} + </ul> + </div> + </> + ); +} + +const catList = []; +for (let i = 0; i < 10; i++) { + catList.push({ + id: i, + imageUrl: "https://placekitten.com/250/200?image=" + i, + }); +} +``` + +```css +div { + width: 100%; + overflow: hidden; +} + +nav { + text-align: center; +} + +button { + margin: 0.25rem; +} + +ul, +li { + list-style: none; + white-space: nowrap; +} + +li { + display: inline; + padding: 0.5rem; +} + +img { + padding: 10px; + margin: -10px; + transition: background 0.2s linear; +} + +.active { + background: rgba(0, 100, 150, 0.4); +} +``` + +<Solution> + +You can declare a `selectedRef`, and then pass it conditionally only to the current image: + +```js +<li ref={index === i ? selectedRef : null}> +``` + +When `index === i`, meaning that the image is the selected one, the `<li>` will receive the `selectedRef`. React will make sure that `selectedRef.current` always points at the correct DOM node. + +Note that the `flushSync` call is necessary to force React to update the DOM before the scroll. Otherwise, `selectedRef.current` would always point at the previously selected item. + +```js +import { useRef, useState } from "react"; +import { flushSync } from "react-dom"; + +export default function CatFriends() { + const selectedRef = useRef(null); + const [index, setIndex] = useState(0); + + return ( + <> + <nav> + <button + on_click={() => { + flushSync(() => { + if (index < catList.length - 1) { + setIndex(index + 1); + } else { + setIndex(0); + } + }); + selectedRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + }} + > + Next + </button> + </nav> + <div> + <ul> + {catList.map((cat, i) => ( + <li key={cat.id} ref={index === i ? selectedRef : null}> + <img + className={index === i ? "active" : ""} + src={cat.imageUrl} + alt={"Cat #" + cat.id} + /> + </li> + ))} + </ul> + </div> + </> + ); +} + +const catList = []; +for (let i = 0; i < 10; i++) { + catList.push({ + id: i, + imageUrl: "https://placekitten.com/250/200?image=" + i, + }); +} +``` + +```css +div { + width: 100%; + overflow: hidden; +} + +nav { + text-align: center; +} + +button { + margin: 0.25rem; +} + +ul, +li { + list-style: none; + white-space: nowrap; +} + +li { + display: inline; + padding: 0.5rem; +} + +img { + padding: 10px; + margin: -10px; + transition: background 0.2s linear; +} + +.active { + background: rgba(0, 100, 150, 0.4); +} +``` + +</Solution> + +#### Focus the search field with separate components + +Make it so that clicking the "Search" button puts focus into the field. Note that each component is defined in a separate file and shouldn't be moved out of it. How do you connect them together? + +<Hint> + +You'll need `forwardRef` to opt into exposing a DOM node from your own component like `SearchInput`. + +</Hint> + +```js +import SearchButton from "./SearchButton.js"; +import SearchInput from "./SearchInput.js"; + +export default function Page() { + return ( + <> + <nav> + <SearchButton /> + </nav> + <SearchInput /> + </> + ); +} +``` + +```js +export default function SearchButton() { + return <button>Search</button>; +} +``` + +```js +export default function SearchInput() { + return <input placeholder="Looking for something?" />; +} +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +``` + +<Solution> + +You'll need to add an `on_click` prop to the `SearchButton`, and make the `SearchButton` pass it down to the browser `<button>`. You'll also pass a ref down to `<SearchInput>`, which will forward it to the real `<input>` and populate it. Finally, in the click handler, you'll call `focus` on the DOM node stored inside that ref. + +```js +import { useRef } from "react"; +import SearchButton from "./SearchButton.js"; +import SearchInput from "./SearchInput.js"; + +export default function Page() { + const inputRef = useRef(null); + return ( + <> + <nav> + <SearchButton + on_click={() => { + inputRef.current.focus(); + }} + /> + </nav> + <SearchInput ref={inputRef} /> + </> + ); +} +``` + +```js +export default function SearchButton({ on_click }) { + return <button on_click={on_click}>Search</button>; +} +``` + +```js +import { forwardRef } from "react"; + +export default forwardRef(function SearchInput(props, ref) { + return <input ref={ref} placeholder="Looking for something?" />; +}); +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +``` + +</Solution> + +</Challenges> diff --git a/src/py/reactpy/tests/tooling/__init__.py b/docs/src/learn/manually-register-a-client.md similarity index 100% rename from src/py/reactpy/tests/tooling/__init__.py rename to docs/src/learn/manually-register-a-client.md diff --git a/docs/src/learn/passing-data-deeply-with-context.md b/docs/src/learn/passing-data-deeply-with-context.md new file mode 100644 index 000000000..202326622 --- /dev/null +++ b/docs/src/learn/passing-data-deeply-with-context.md @@ -0,0 +1,1088 @@ +## Overview + +<p class="intro" markdown> + +Usually, you will pass information from a parent component to a child component via props. But passing props can become verbose and inconvenient if you have to pass them through many components in the middle, or if many components in your app need the same information. _Context_ lets the parent component make some information available to any component in the tree below it—no matter how deep—without passing it explicitly through props. + +</p> + +!!! summary "You will learn" + + - What "prop drilling" is + - How to replace repetitive prop passing with context + - Common use cases for context + - Common alternatives to context + +## The problem with passing props + +[Passing props](/learn/passing-props-to-a-component) is a great way to explicitly pipe data through your UI tree to the components that use it. + +But passing props can become verbose and inconvenient when you need to pass some prop deeply through the tree, or if many components need the same prop. The nearest common ancestor could be far removed from the components that need data, and [lifting state up](/learn/sharing-state-between-components) that high can lead to a situation called "prop drilling". + +<!-- TODO: Diagram --> + +Wouldn't it be great if there were a way to "teleport" data to the components in the tree that need it without passing props? With React's context feature, there is! + +## Context: an alternative to passing props + +Context lets a parent component provide data to the entire tree below it. There are many uses for context. Here is one example. Consider this `Heading` component that accepts a `level` for its size: + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section> + <Heading level={1}>Title</Heading> + <Heading level={2}>Heading</Heading> + <Heading level={3}>Sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={5}>Sub-sub-sub-heading</Heading> + <Heading level={6}>Sub-sub-sub-sub-heading</Heading> + </Section> + ); +} +``` + +```js +export default function Section({ children }) { + return <section className="section">{children}</section>; +} +``` + +```js +export default function Heading({ level, children }) { + switch (level) { + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +Let's say you want multiple headings within the same `Section` to always have the same size: + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section> + <Heading level={1}>Title</Heading> + <Section> + <Heading level={2}>Heading</Heading> + <Heading level={2}>Heading</Heading> + <Heading level={2}>Heading</Heading> + <Section> + <Heading level={3}>Sub-heading</Heading> + <Heading level={3}>Sub-heading</Heading> + <Heading level={3}>Sub-heading</Heading> + <Section> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> + </Section> + </Section> + </Section> + </Section> + ); +} +``` + +```js +export default function Section({ children }) { + return <section className="section">{children}</section>; +} +``` + +```js +export default function Heading({ level, children }) { + switch (level) { + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +Currently, you pass the `level` prop to each `<Heading>` separately: + +```js +<Section> + <Heading level={3}>About</Heading> + <Heading level={3}>Photos</Heading> + <Heading level={3}>Videos</Heading> +</Section> +``` + +It would be nice if you could pass the `level` prop to the `<Section>` component instead and remove it from the `<Heading>`. This way you could enforce that all headings in the same section have the same size: + +```js +<Section level={3}> + <Heading>About</Heading> + <Heading>Photos</Heading> + <Heading>Videos</Heading> +</Section> +``` + +But how can the `<Heading>` component know the level of its closest `<Section>`? **That would require some way for a child to "ask" for data from somewhere above in the tree.** + +You can't do it with props alone. This is where context comes into play. You will do it in three steps: + +1. **Create** a context. (You can call it `LevelContext`, since it's for the heading level.) +2. **Use** that context from the component that needs the data. (`Heading` will use `LevelContext`.) +3. **Provide** that context from the component that specifies the data. (`Section` will provide `LevelContext`.) + +Context lets a parent--even a distant one!--provide some data to the entire tree inside of it. + +<!-- TODO: Diagram --> + +### Step 1: Create the context + +First, you need to create the context. You'll need to **export it from a file** so that your components can use it: + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section> + <Heading level={1}>Title</Heading> + <Section> + <Heading level={2}>Heading</Heading> + <Heading level={2}>Heading</Heading> + <Heading level={2}>Heading</Heading> + <Section> + <Heading level={3}>Sub-heading</Heading> + <Heading level={3}>Sub-heading</Heading> + <Heading level={3}>Sub-heading</Heading> + <Section> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> + </Section> + </Section> + </Section> + </Section> + ); +} +``` + +```js +export default function Section({ children }) { + return <section className="section">{children}</section>; +} +``` + +```js +export default function Heading({ level, children }) { + switch (level) { + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```js +import { createContext } from "react"; + +export const LevelContext = createContext(1); +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +The only argument to `createContext` is the _default_ value. Here, `1` refers to the biggest heading level, but you could pass any kind of value (even an object). You will see the significance of the default value in the next step. + +### Step 2: Use the context + +Import the `useContext` Hook from React and your context: + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; +``` + +Currently, the `Heading` component reads `level` from props: + +```js +export default function Heading({ level, children }) { + // ... +} +``` + +Instead, remove the `level` prop and read the value from the context you just imported, `LevelContext`: + +```js +export default function Heading({ children }) { + const level = useContext(LevelContext); + // ... +} +``` + +`useContext` is a Hook. Just like `useState` and `useReducer`, you can only call a Hook immediately inside a React component (not inside loops or conditions). **`useContext` tells React that the `Heading` component wants to read the `LevelContext`.** + +Now that the `Heading` component doesn't have a `level` prop, you don't need to pass the level prop to `Heading` in your JSX like this anymore: + +```js +<Section> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> + <Heading level={4}>Sub-sub-heading</Heading> +</Section> +``` + +Update the JSX so that it's the `Section` that receives it instead: + +```jsx +<Section level={4}> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> +</Section> +``` + +As a reminder, this is the markup that you were trying to get working: + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section level={1}> + <Heading>Title</Heading> + <Section level={2}> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Section level={3}> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Section level={4}> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + </Section> + </Section> + </Section> + </Section> + ); +} +``` + +```js +export default function Section({ children }) { + return <section className="section">{children}</section>; +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Heading({ children }) { + const level = useContext(LevelContext); + switch (level) { + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```js +import { createContext } from "react"; + +export const LevelContext = createContext(1); +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +Notice this example doesn't quite work, yet! All the headings have the same size because **even though you're _using_ the context, you have not _provided_ it yet.** React doesn't know where to get it! + +If you don't provide the context, React will use the default value you've specified in the previous step. In this example, you specified `1` as the argument to `createContext`, so `useContext(LevelContext)` returns `1`, setting all those headings to `<h1>`. Let's fix this problem by having each `Section` provide its own context. + +### Step 3: Provide the context + +The `Section` component currently renders its children: + +```js +export default function Section({ children }) { + return <section className="section">{children}</section>; +} +``` + +**Wrap them with a context provider** to provide the `LevelContext` to them: + +```js +import { LevelContext } from "./LevelContext.js"; + +export default function Section({ level, children }) { + return ( + <section className="section"> + <LevelContext.Provider value={level}> + {children} + </LevelContext.Provider> + </section> + ); +} +``` + +This tells React: "if any component inside this `<Section>` asks for `LevelContext`, give them this `level`." The component will use the value of the nearest `<LevelContext.Provider>` in the UI tree above it. + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section level={1}> + <Heading>Title</Heading> + <Section level={2}> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Section level={3}> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Section level={4}> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + </Section> + </Section> + </Section> + </Section> + ); +} +``` + +```js +import { LevelContext } from "./LevelContext.js"; + +export default function Section({ level, children }) { + return ( + <section className="section"> + <LevelContext.Provider value={level}> + {children} + </LevelContext.Provider> + </section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Heading({ children }) { + const level = useContext(LevelContext); + switch (level) { + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```js +import { createContext } from "react"; + +export const LevelContext = createContext(1); +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +It's the same result as the original code, but you did not need to pass the `level` prop to each `Heading` component! Instead, it "figures out" its heading level by asking the closest `Section` above: + +1. You pass a `level` prop to the `<Section>`. +2. `Section` wraps its children into `<LevelContext.Provider value={level}>`. +3. `Heading` asks the closest value of `LevelContext` above with `useContext(LevelContext)`. + +## Using and providing context from the same component + +Currently, you still have to specify each section's `level` manually: + +```js +export default function Page() { + return ( + <Section level={1}> + ... + <Section level={2}> + ... + <Section level={3}> + ... +``` + +Since context lets you read information from a component above, each `Section` could read the `level` from the `Section` above, and pass `level + 1` down automatically. Here is how you could do it: + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Section({ children }) { + const level = useContext(LevelContext); + return ( + <section className="section"> + <LevelContext.Provider value={level + 1}> + {children} + </LevelContext.Provider> + </section> + ); +} +``` + +With this change, you don't need to pass the `level` prop _either_ to the `<Section>` or to the `<Heading>`: + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section> + <Heading>Title</Heading> + <Section> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Section> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Section> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + </Section> + </Section> + </Section> + </Section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Section({ children }) { + const level = useContext(LevelContext); + return ( + <section className="section"> + <LevelContext.Provider value={level + 1}> + {children} + </LevelContext.Provider> + </section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Heading({ children }) { + const level = useContext(LevelContext); + switch (level) { + case 0: + throw Error("Heading must be inside a Section!"); + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```js +import { createContext } from "react"; + +export const LevelContext = createContext(0); +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +Now both `Heading` and `Section` read the `LevelContext` to figure out how "deep" they are. And the `Section` wraps its children into the `LevelContext` to specify that anything inside of it is at a "deeper" level. + +<Note> + +This example uses heading levels because they show visually how nested components can override context. But context is useful for many other use cases too. You can pass down any information needed by the entire subtree: the current color theme, the currently logged in user, and so on. + +</Note> + +## Context passes through intermediate components + +You can insert as many components as you like between the component that provides context and the one that uses it. This includes both built-in components like `<div>` and components you might build yourself. + +In this example, the same `Post` component (with a dashed border) is rendered at two different nesting levels. Notice that the `<Heading>` inside of it gets its level automatically from the closest `<Section>`: + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function ProfilePage() { + return ( + <Section> + <Heading>My Profile</Heading> + <Post title="Hello traveller!" body="Read about my adventures." /> + <AllPosts /> + </Section> + ); +} + +function AllPosts() { + return ( + <Section> + <Heading>Posts</Heading> + <RecentPosts /> + </Section> + ); +} + +function RecentPosts() { + return ( + <Section> + <Heading>Recent Posts</Heading> + <Post title="Flavors of Lisbon" body="...those pastéis de nata!" /> + <Post + title="Buenos Aires in the rhythm of tango" + body="I loved it!" + /> + </Section> + ); +} + +function Post({ title, body }) { + return ( + <Section isFancy={true}> + <Heading>{title}</Heading> + <p> + <i>{body}</i> + </p> + </Section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Section({ children, isFancy }) { + const level = useContext(LevelContext); + return ( + <section className={"section " + (isFancy ? "fancy" : "")}> + <LevelContext.Provider value={level + 1}> + {children} + </LevelContext.Provider> + </section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Heading({ children }) { + const level = useContext(LevelContext); + switch (level) { + case 0: + throw Error("Heading must be inside a Section!"); + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```js +import { createContext } from "react"; + +export const LevelContext = createContext(0); +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} + +.fancy { + border: 4px dashed pink; +} +``` + +You didn't do anything special for this to work. A `Section` specifies the context for the tree inside it, so you can insert a `<Heading>` anywhere, and it will have the correct size. Try it in the sandbox above! + +**Context lets you write components that "adapt to their surroundings" and display themselves differently depending on _where_ (or, in other words, _in which context_) they are being rendered.** + +How context works might remind you of [CSS property inheritance.](https://developer.mozilla.org/en-US/docs/Web/CSS/inheritance) In CSS, you can specify `color: blue` for a `<div>`, and any DOM node inside of it, no matter how deep, will inherit that color unless some other DOM node in the middle overrides it with `color: green`. Similarly, in React, the only way to override some context coming from above is to wrap children into a context provider with a different value. + +In CSS, different properties like `color` and `background-color` don't override each other. You can set all `<div>`'s `color` to red without impacting `background-color`. Similarly, **different React contexts don't override each other.** Each context that you make with `createContext()` is completely separate from other ones, and ties together components using and providing _that particular_ context. One component may use or provide many different contexts without a problem. + +## Before you use context + +Context is very tempting to use! However, this also means it's too easy to overuse it. **Just because you need to pass some props several levels deep doesn't mean you should put that information into context.** + +Here's a few alternatives you should consider before using context: + +1. **Start by [passing props.](/learn/passing-props-to-a-component)** If your components are not trivial, it's not unusual to pass a dozen props down through a dozen components. It may feel like a slog, but it makes it very clear which components use which data! The person maintaining your code will be glad you've made the data flow explicit with props. +2. **Extract components and [pass JSX as `children`](/learn/passing-props-to-a-component#passing-jsx-as-children) to them.** If you pass some data through many layers of intermediate components that don't use that data (and only pass it further down), this often means that you forgot to extract some components along the way. For example, maybe you pass data props like `posts` to visual components that don't use them directly, like `<Layout posts={posts} />`. Instead, make `Layout` take `children` as a prop, and render `<Layout><Posts posts={posts} /></Layout>`. This reduces the number of layers between the component specifying the data and the one that needs it. + +If neither of these approaches works well for you, consider context. + +## Use cases for context + +- **Theming:** If your app lets the user change its appearance (e.g. dark mode), you can put a context provider at the top of your app, and use that context in components that need to adjust their visual look. +- **Current account:** Many components might need to know the currently logged in user. Putting it in context makes it convenient to read it anywhere in the tree. Some apps also let you operate multiple accounts at the same time (e.g. to leave a comment as a different user). In those cases, it can be convenient to wrap a part of the UI into a nested provider with a different current account value. +- **Routing:** Most routing solutions use context internally to hold the current route. This is how every link "knows" whether it's active or not. If you build your own router, you might want to do it too. +- **Managing state:** As your app grows, you might end up with a lot of state closer to the top of your app. Many distant components below may want to change it. It is common to [use a reducer together with context](/learn/scaling-up-with-reducer-and-context) to manage complex state and pass it down to distant components without too much hassle. + +Context is not limited to static values. If you pass a different value on the next render, React will update all the components reading it below! This is why context is often used in combination with state. + +In general, if some information is needed by distant components in different parts of the tree, it's a good indication that context will help you. + +<Recap> + +- Context lets a component provide some information to the entire tree below it. +- To pass context: + 1. Create and export it with `export const MyContext = createContext(defaultValue)`. + 2. Pass it to the `useContext(MyContext)` Hook to read it in any child component, no matter how deep. + 3. Wrap children into `<MyContext.Provider value={...}>` to provide it from a parent. +- Context passes through any components in the middle. +- Context lets you write components that "adapt to their surroundings". +- Before you use context, try passing props or passing JSX as `children`. + +</Recap> + +<Challenges> + +#### Replace prop drilling with context + +In this example, toggling the checkbox changes the `imageSize` prop passed to each `<PlaceImage>`. The checkbox state is held in the top-level `App` component, but each `<PlaceImage>` needs to be aware of it. + +Currently, `App` passes `imageSize` to `List`, which passes it to each `Place`, which passes it to the `PlaceImage`. Remove the `imageSize` prop, and instead pass it from the `App` component directly to `PlaceImage`. + +You can declare context in `Context.js`. + +```js +import { useState } from "react"; +import { places } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +export default function App() { + const [isLarge, setIsLarge] = useState(false); + const imageSize = isLarge ? 150 : 100; + return ( + <> + <label> + <input + type="checkbox" + checked={isLarge} + onChange={(e) => { + setIsLarge(e.target.checked); + }} + /> + Use large images + </label> + <hr /> + <List imageSize={imageSize} /> + </> + ); +} + +function List({ imageSize }) { + const listItems = places.map((place) => ( + <li key={place.id}> + <Place place={place} imageSize={imageSize} /> + </li> + )); + return <ul>{listItems}</ul>; +} + +function Place({ place, imageSize }) { + return ( + <> + <PlaceImage place={place} imageSize={imageSize} /> + <p> + <b>{place.name}</b> + {": " + place.description} + </p> + </> + ); +} + +function PlaceImage({ place, imageSize }) { + return ( + <img + src={getImageUrl(place)} + alt={place.name} + width={imageSize} + height={imageSize} + /> + ); +} +``` + +```js + +``` + +```js +export const places = [ + { + id: 0, + name: "Bo-Kaap in Cape Town, South Africa", + description: + "The tradition of choosing bright colors for houses began in the late 20th century.", + imageId: "K9HVAGH", + }, + { + id: 1, + name: "Rainbow Village in Taichung, Taiwan", + description: + "To save the houses from demolition, Huang Yung-Fu, a local resident, painted all 1,200 of them in 1924.", + imageId: "9EAYZrt", + }, + { + id: 2, + name: "Macromural de Pachuca, Mexico", + description: + "One of the largest murals in the world covering homes in a hillside neighborhood.", + imageId: "DgXHVwu", + }, + { + id: 3, + name: "Selarón Staircase in Rio de Janeiro, Brazil", + description: + 'This landmark was created by Jorge Selarón, a Chilean-born artist, as a "tribute to the Brazilian people."', + imageId: "aeO3rpI", + }, + { + id: 4, + name: "Burano, Italy", + description: + "The houses are painted following a specific color system dating back to 16th century.", + imageId: "kxsph5C", + }, + { + id: 5, + name: "Chefchaouen, Marocco", + description: + "There are a few theories on why the houses are painted blue, including that the color repells mosquitos or that it symbolizes sky and heaven.", + imageId: "rTqKo46", + }, + { + id: 6, + name: "Gamcheon Culture Village in Busan, South Korea", + description: + "In 2009, the village was converted into a cultural hub by painting the houses and featuring exhibitions and art installations.", + imageId: "ZfQOOzf", + }, +]; +``` + +```js +export function getImageUrl(place) { + return "https://i.imgur.com/" + place.imageId + "l.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +``` + +<Solution> + +Remove `imageSize` prop from all the components. + +Create and export `ImageSizeContext` from `Context.js`. Then wrap the List into `<ImageSizeContext.Provider value={imageSize}>` to pass the value down, and `useContext(ImageSizeContext)` to read it in the `PlaceImage`: + +```js +import { useState, useContext } from "react"; +import { places } from "./data.js"; +import { getImageUrl } from "./utils.js"; +import { ImageSizeContext } from "./Context.js"; + +export default function App() { + const [isLarge, setIsLarge] = useState(false); + const imageSize = isLarge ? 150 : 100; + return ( + <ImageSizeContext.Provider value={imageSize}> + <label> + <input + type="checkbox" + checked={isLarge} + onChange={(e) => { + setIsLarge(e.target.checked); + }} + /> + Use large images + </label> + <hr /> + <List /> + </ImageSizeContext.Provider> + ); +} + +function List() { + const listItems = places.map((place) => ( + <li key={place.id}> + <Place place={place} /> + </li> + )); + return <ul>{listItems}</ul>; +} + +function Place({ place }) { + return ( + <> + <PlaceImage place={place} /> + <p> + <b>{place.name}</b> + {": " + place.description} + </p> + </> + ); +} + +function PlaceImage({ place }) { + const imageSize = useContext(ImageSizeContext); + return ( + <img + src={getImageUrl(place)} + alt={place.name} + width={imageSize} + height={imageSize} + /> + ); +} +``` + +```js +import { createContext } from "react"; + +export const ImageSizeContext = createContext(500); +``` + +```js +export const places = [ + { + id: 0, + name: "Bo-Kaap in Cape Town, South Africa", + description: + "The tradition of choosing bright colors for houses began in the late 20th century.", + imageId: "K9HVAGH", + }, + { + id: 1, + name: "Rainbow Village in Taichung, Taiwan", + description: + "To save the houses from demolition, Huang Yung-Fu, a local resident, painted all 1,200 of them in 1924.", + imageId: "9EAYZrt", + }, + { + id: 2, + name: "Macromural de Pachuca, Mexico", + description: + "One of the largest murals in the world covering homes in a hillside neighborhood.", + imageId: "DgXHVwu", + }, + { + id: 3, + name: "Selarón Staircase in Rio de Janeiro, Brazil", + description: + 'This landmark was created by Jorge Selarón, a Chilean-born artist, as a "tribute to the Brazilian people".', + imageId: "aeO3rpI", + }, + { + id: 4, + name: "Burano, Italy", + description: + "The houses are painted following a specific color system dating back to 16th century.", + imageId: "kxsph5C", + }, + { + id: 5, + name: "Chefchaouen, Marocco", + description: + "There are a few theories on why the houses are painted blue, including that the color repells mosquitos or that it symbolizes sky and heaven.", + imageId: "rTqKo46", + }, + { + id: 6, + name: "Gamcheon Culture Village in Busan, South Korea", + description: + "In 2009, the village was converted into a cultural hub by painting the houses and featuring exhibitions and art installations.", + imageId: "ZfQOOzf", + }, +]; +``` + +```js +export function getImageUrl(place) { + return "https://i.imgur.com/" + place.imageId + "l.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +``` + +Note how components in the middle don't need to pass `imageSize` anymore. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/passing-props-to-a-component.md b/docs/src/learn/passing-props-to-a-component.md new file mode 100644 index 000000000..f9f633de2 --- /dev/null +++ b/docs/src/learn/passing-props-to-a-component.md @@ -0,0 +1,1076 @@ +## Overview + +<p class="intro" markdown> + +React components use _props_ to communicate with each other. Every parent component can pass some information to its child components by giving them props. Props might remind you of HTML attributes, but you can pass any JavaScript value through them, including objects, arrays, and functions. + +</p> + +!!! summary "You will learn" + + - How to pass props to a component + - How to read props from a component + - How to specify default values for props + - How to pass some JSX to a component + - How props change over time + +## Familiar props + +Props are the information that you pass to a JSX tag. For example, `className`, `src`, `alt`, `width`, and `height` are some of the props you can pass to an `<img>`: + +```js +function Avatar() { + return ( + <img + className="avatar" + src="https://i.imgur.com/1bX5QH6.jpg" + alt="Lin Lanying" + width={100} + height={100} + /> + ); +} + +export default function Profile() { + return <Avatar />; +} +``` + +```css +body { + min-height: 120px; +} +.avatar { + margin: 20px; + border-radius: 50%; +} +``` + +The props you can pass to an `<img>` tag are predefined (ReactDOM conforms to [the HTML standard](https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element)). But you can pass any props to _your own_ components, such as `<Avatar>`, to customize them. Here's how! + +## Passing props to a component + +In this code, the `Profile` component isn't passing any props to its child component, `Avatar`: + +```js +export default function Profile() { + return <Avatar />; +} +``` + +You can give `Avatar` some props in two steps. + +### Step 1: Pass props to the child component + +First, pass some props to `Avatar`. For example, let's pass two props: `person` (an object), and `size` (a number): + +```js +export default function Profile() { + return ( + <Avatar + person={{ name: "Lin Lanying", imageId: "1bX5QH6" }} + size={100} + /> + ); +} +``` + +<Note> + +If double curly braces after `person=` confuse you, recall [they're merely an object](/learn/javascript-in-jsx-with-curly-braces#using-double-curlies-css-and-other-objects-in-jsx) inside the JSX curlies. + +</Note> + +Now you can read these props inside the `Avatar` component. + +### Step 2: Read props inside the child component + +You can read these props by listing their names `person, size` separated by the commas inside `({` and `})` directly after `function Avatar`. This lets you use them inside the `Avatar` code, like you would with a variable. + +```js +function Avatar({ person, size }) { + // person and size are available here +} +``` + +Add some logic to `Avatar` that uses the `person` and `size` props for rendering, and you're done. + +Now you can configure `Avatar` to render in many different ways with different props. Try tweaking the values! + +```js +import { getImageUrl } from "./utils.js"; + +function Avatar({ person, size }) { + return ( + <img + className="avatar" + src={getImageUrl(person)} + alt={person.name} + width={size} + height={size} + /> + ); +} + +export default function Profile() { + return ( + <div> + <Avatar + size={100} + person={{ + name: "Katsuko Saruhashi", + imageId: "YfeOqp2", + }} + /> + <Avatar + size={80} + person={{ + name: "Aklilu Lemma", + imageId: "OKS67lh", + }} + /> + <Avatar + size={50} + person={{ + name: "Lin Lanying", + imageId: "1bX5QH6", + }} + /> + </div> + ); +} +``` + +```js +export function getImageUrl(person, size = "s") { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +body { + min-height: 120px; +} +.avatar { + margin: 10px; + border-radius: 50%; +} +``` + +Props let you think about parent and child components independently. For example, you can change the `person` or the `size` props inside `Profile` without having to think about how `Avatar` uses them. Similarly, you can change how the `Avatar` uses these props, without looking at the `Profile`. + +You can think of props like "knobs" that you can adjust. They serve the same role as arguments serve for functions—in fact, props _are_ the only argument to your component! React component functions accept a single argument, a `props` object: + +```js +function Avatar(props) { + let person = props.person; + let size = props.size; + // ... +} +``` + +Usually you don't need the whole `props` object itself, so you destructure it into individual props. + +<Pitfall> + +**Don't miss the pair of `{` and `}` curlies** inside of `(` and `)` when declaring props: + +```js +function Avatar({ person, size }) { + // ... +} +``` + +This syntax is called ["destructuring"](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Unpacking_fields_from_objects_passed_as_a_function_parameter) and is equivalent to reading properties from a function parameter: + +```js +function Avatar(props) { + let person = props.person; + let size = props.size; + // ... +} +``` + +</Pitfall> + +## Specifying a default value for a prop + +If you want to give a prop a default value to fall back on when no value is specified, you can do it with the destructuring by putting `=` and the default value right after the parameter: + +```js +function Avatar({ person, size = 100 }) { + // ... +} +``` + +Now, if `<Avatar person={...} />` is rendered with no `size` prop, the `size` will be set to `100`. + +The default value is only used if the `size` prop is missing or if you pass `size={undefined}`. But if you pass `size={null}` or `size={0}`, the default value will **not** be used. + +## Forwarding props with the JSX spread syntax + +Sometimes, passing props gets very repetitive: + +```js +function Profile({ person, size, isSepia, thickBorder }) { + return ( + <div className="card"> + <Avatar + person={person} + size={size} + isSepia={isSepia} + thickBorder={thickBorder} + /> + </div> + ); +} +``` + +There's nothing wrong with repetitive code—it can be more legible. But at times you may value conciseness. Some components forward all of their props to their children, like how this `Profile` does with `Avatar`. Because they don't use any of their props directly, it can make sense to use a more concise "spread" syntax: + +```js +function Profile(props) { + return ( + <div className="card"> + <Avatar {...props} /> + </div> + ); +} +``` + +This forwards all of `Profile`'s props to the `Avatar` without listing each of their names. + +**Use spread syntax with restraint.** If you're using it in every other component, something is wrong. Often, it indicates that you should split your components and pass children as JSX. More on that next! + +## Passing JSX as children + +It is common to nest built-in browser tags: + +```js +<div> + <img /> +</div> +``` + +Sometimes you'll want to nest your own components the same way: + +```js +<Card> + <Avatar /> +</Card> +``` + +When you nest content inside a JSX tag, the parent component will receive that content in a prop called `children`. For example, the `Card` component below will receive a `children` prop set to `<Avatar />` and render it in a wrapper div: + +```js +import Avatar from "./Avatar.js"; + +function Card({ children }) { + return <div className="card">{children}</div>; +} + +export default function Profile() { + return ( + <Card> + <Avatar + size={100} + person={{ + name: "Katsuko Saruhashi", + imageId: "YfeOqp2", + }} + /> + </Card> + ); +} +``` + +```js +import { getImageUrl } from "./utils.js"; + +export default function Avatar({ person, size }) { + return ( + <img + className="avatar" + src={getImageUrl(person)} + alt={person.name} + width={size} + height={size} + /> + ); +} +``` + +```js +export function getImageUrl(person, size = "s") { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.card { + width: fit-content; + margin: 5px; + padding: 5px; + font-size: 20px; + text-align: center; + border: 1px solid #aaa; + border-radius: 20px; + background: #fff; +} +.avatar { + margin: 20px; + border-radius: 50%; +} +``` + +Try replacing the `<Avatar>` inside `<Card>` with some text to see how the `Card` component can wrap any nested content. It doesn't need to "know" what's being rendered inside of it. You will see this flexible pattern in many places. + +You can think of a component with a `children` prop as having a "hole" that can be "filled in" by its parent components with arbitrary JSX. You will often use the `children` prop for visual wrappers: panels, grids, etc. + +<Illustration src="/images/docs/illustrations/i_children-prop.png" alt='A puzzle-like Card tile with a slot for "children" pieces like text and Avatar' /> + +## How props change over time + +The `Clock` component below receives two props from its parent component: `color` and `time`. (The parent component's code is omitted because it uses [state](/learn/state-a-components-memory), which we won't dive into just yet.) + +Try changing the color in the select box below: + +```js +export default function Clock({ color, time }) { + return <h1 style={{ color: color }}>{time}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + const [color, setColor] = useState("lightcoral"); + return ( + <div> + <p> + Pick a color:{" "} + <select + value={color} + onChange={(e) => setColor(e.target.value)} + > + <option value="lightcoral">lightcoral</option> + <option value="midnightblue">midnightblue</option> + <option value="rebeccapurple">rebeccapurple</option> + </select> + </p> + <Clock color={color} time={time.toLocaleTimeString()} /> + </div> + ); +} +``` + +This example illustrates that **a component may receive different props over time.** Props are not always static! Here, the `time` prop changes every second, and the `color` prop changes when you select another color. Props reflect a component's data at any point in time, rather than only in the beginning. + +However, props are [immutable](https://en.wikipedia.org/wiki/Immutable_object)—a term from computer science meaning "unchangeable". When a component needs to change its props (for example, in response to a user interaction or new data), it will have to "ask" its parent component to pass it _different props_—a new object! Its old props will then be cast aside, and eventually the JavaScript engine will reclaim the memory taken by them. + +**Don't try to "change props".** When you need to respond to the user input (like changing the selected color), you will need to "set state", which you can learn about in [State: A Component's Memory.](/learn/state-a-components-memory) + +<Recap> + +- To pass props, add them to the JSX, just like you would with HTML attributes. +- To read props, use the `function Avatar({ person, size })` destructuring syntax. +- You can specify a default value like `size = 100`, which is used for missing and `undefined` props. +- You can forward all props with `<Avatar {...props} />` JSX spread syntax, but don't overuse it! +- Nested JSX like `<Card><Avatar /></Card>` will appear as `Card` component's `children` prop. +- Props are read-only snapshots in time: every render receives a new version of props. +- You can't change props. When you need interactivity, you'll need to set state. + +</Recap> + +<Challenges> + +#### Extract a component + +This `Gallery` component contains some very similar markup for two profiles. Extract a `Profile` component out of it to reduce the duplication. You'll need to choose what props to pass to it. + +```js +import { getImageUrl } from "./utils.js"; + +export default function Gallery() { + return ( + <div> + <h1>Notable Scientists</h1> + <section className="profile"> + <h2>Maria Skłodowska-Curie</h2> + <img + className="avatar" + src={getImageUrl("szV5sdG")} + alt="Maria Skłodowska-Curie" + width={70} + height={70} + /> + <ul> + <li> + <b>Profession: </b> + physicist and chemist + </li> + <li> + <b>Awards: 4 </b> + (Nobel Prize in Physics, Nobel Prize in Chemistry, Davy Medal, + Matteucci Medal) + </li> + <li> + <b>Discovered: </b> + polonium (element) + </li> + </ul> + </section> + <section className="profile"> + <h2>Katsuko Saruhashi</h2> + <img + className="avatar" + src={getImageUrl("YfeOqp2")} + alt="Katsuko Saruhashi" + width={70} + height={70} + /> + <ul> + <li> + <b>Profession: </b> + geochemist + </li> + <li> + <b>Awards: 2 </b> + (Miyake Prize for geochemistry, Tanaka Prize) + </li> + <li> + <b>Discovered: </b>a method for measuring carbon dioxide + in seawater + </li> + </ul> + </section> + </div> + ); +} +``` + +```js +export function getImageUrl(imageId, size = "s") { + return "https://i.imgur.com/" + imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 5px; + border-radius: 50%; + min-height: 70px; +} +.profile { + border: 1px solid #aaa; + border-radius: 6px; + margin-top: 20px; + padding: 10px; +} +h1, +h2 { + margin: 5px; +} +h1 { + margin-bottom: 10px; +} +ul { + padding: 0px 10px 0px 20px; +} +li { + margin: 5px; +} +``` + +<Hint> + +Start by extracting the markup for one of the scientists. Then find the pieces that don't match it in the second example, and make them configurable by props. + +</Hint> + +<Solution> + +In this solution, the `Profile` component accepts multiple props: `imageId` (a string), `name` (a string), `profession` (a string), `awards` (an array of strings), `discovery` (a string), and `imageSize` (a number). + +Note that the `imageSize` prop has a default value, which is why we don't pass it to the component. + +```js +import { getImageUrl } from "./utils.js"; + +function Profile({ + imageId, + name, + profession, + awards, + discovery, + imageSize = 70, +}) { + return ( + <section className="profile"> + <h2>{name}</h2> + <img + className="avatar" + src={getImageUrl(imageId)} + alt={name} + width={imageSize} + height={imageSize} + /> + <ul> + <li> + <b>Profession:</b> {profession} + </li> + <li> + <b>Awards: {awards.length} </b>({awards.join(", ")}) + </li> + <li> + <b>Discovered: </b> + {discovery} + </li> + </ul> + </section> + ); +} + +export default function Gallery() { + return ( + <div> + <h1>Notable Scientists</h1> + <Profile + imageId="szV5sdG" + name="Maria Skłodowska-Curie" + profession="physicist and chemist" + discovery="polonium (chemical element)" + awards={[ + "Nobel Prize in Physics", + "Nobel Prize in Chemistry", + "Davy Medal", + "Matteucci Medal", + ]} + /> + <Profile + imageId="YfeOqp2" + name="Katsuko Saruhashi" + profession="geochemist" + discovery="a method for measuring carbon dioxide in seawater" + awards={["Miyake Prize for geochemistry", "Tanaka Prize"]} + /> + </div> + ); +} +``` + +```js +export function getImageUrl(imageId, size = "s") { + return "https://i.imgur.com/" + imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 5px; + border-radius: 50%; + min-height: 70px; +} +.profile { + border: 1px solid #aaa; + border-radius: 6px; + margin-top: 20px; + padding: 10px; +} +h1, +h2 { + margin: 5px; +} +h1 { + margin-bottom: 10px; +} +ul { + padding: 0px 10px 0px 20px; +} +li { + margin: 5px; +} +``` + +Note how you don't need a separate `awardCount` prop if `awards` is an array. Then you can use `awards.length` to count the number of awards. Remember that props can take any values, and that includes arrays too! + +Another solution, which is more similar to the earlier examples on this page, is to group all information about a person in a single object, and pass that object as one prop: + +```js +import { getImageUrl } from "./utils.js"; + +function Profile({ person, imageSize = 70 }) { + const imageSrc = getImageUrl(person); + + return ( + <section className="profile"> + <h2>{person.name}</h2> + <img + className="avatar" + src={imageSrc} + alt={person.name} + width={imageSize} + height={imageSize} + /> + <ul> + <li> + <b>Profession:</b> {person.profession} + </li> + <li> + <b>Awards: {person.awards.length} </b>( + {person.awards.join(", ")}) + </li> + <li> + <b>Discovered: </b> + {person.discovery} + </li> + </ul> + </section> + ); +} + +export default function Gallery() { + return ( + <div> + <h1>Notable Scientists</h1> + <Profile + person={{ + imageId: "szV5sdG", + name: "Maria Skłodowska-Curie", + profession: "physicist and chemist", + discovery: "polonium (chemical element)", + awards: [ + "Nobel Prize in Physics", + "Nobel Prize in Chemistry", + "Davy Medal", + "Matteucci Medal", + ], + }} + /> + <Profile + person={{ + imageId: "YfeOqp2", + name: "Katsuko Saruhashi", + profession: "geochemist", + discovery: + "a method for measuring carbon dioxide in seawater", + awards: ["Miyake Prize for geochemistry", "Tanaka Prize"], + }} + /> + </div> + ); +} +``` + +```js +export function getImageUrl(person, size = "s") { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 5px; + border-radius: 50%; + min-height: 70px; +} +.profile { + border: 1px solid #aaa; + border-radius: 6px; + margin-top: 20px; + padding: 10px; +} +h1, +h2 { + margin: 5px; +} +h1 { + margin-bottom: 10px; +} +ul { + padding: 0px 10px 0px 20px; +} +li { + margin: 5px; +} +``` + +Although the syntax looks slightly different because you're describing properties of a JavaScript object rather than a collection of JSX attributes, these examples are mostly equivalent, and you can pick either approach. + +</Solution> + +#### Adjust the image size based on a prop + +In this example, `Avatar` receives a numeric `size` prop which determines the `<img>` width and height. The `size` prop is set to `40` in this example. However, if you open the image in a new tab, you'll notice that the image itself is larger (`160` pixels). The real image size is determined by which thumbnail size you're requesting. + +Change the `Avatar` component to request the closest image size based on the `size` prop. Specifically, if the `size` is less than `90`, pass `'s'` ("small") rather than `'b'` ("big") to the `getImageUrl` function. Verify that your changes work by rendering avatars with different values of the `size` prop and opening images in a new tab. + +```js +import { getImageUrl } from "./utils.js"; + +function Avatar({ person, size }) { + return ( + <img + className="avatar" + src={getImageUrl(person, "b")} + alt={person.name} + width={size} + height={size} + /> + ); +} + +export default function Profile() { + return ( + <Avatar + size={40} + person={{ + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + }} + /> + ); +} +``` + +```js +export function getImageUrl(person, size) { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 20px; + border-radius: 50%; +} +``` + +<Solution> + +Here is how you could go about it: + +```js +import { getImageUrl } from "./utils.js"; + +function Avatar({ person, size }) { + let thumbnailSize = "s"; + if (size > 90) { + thumbnailSize = "b"; + } + return ( + <img + className="avatar" + src={getImageUrl(person, thumbnailSize)} + alt={person.name} + width={size} + height={size} + /> + ); +} + +export default function Profile() { + return ( + <> + <Avatar + size={40} + person={{ + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + }} + /> + <Avatar + size={120} + person={{ + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + }} + /> + </> + ); +} +``` + +```js +export function getImageUrl(person, size) { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 20px; + border-radius: 50%; +} +``` + +You could also show a sharper image for high DPI screens by taking [`window.devicePixelRatio`](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) into account: + +```js +import { getImageUrl } from "./utils.js"; + +const ratio = window.devicePixelRatio; + +function Avatar({ person, size }) { + let thumbnailSize = "s"; + if (size * ratio > 90) { + thumbnailSize = "b"; + } + return ( + <img + className="avatar" + src={getImageUrl(person, thumbnailSize)} + alt={person.name} + width={size} + height={size} + /> + ); +} + +export default function Profile() { + return ( + <> + <Avatar + size={40} + person={{ + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + }} + /> + <Avatar + size={70} + person={{ + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + }} + /> + <Avatar + size={120} + person={{ + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + }} + /> + </> + ); +} +``` + +```js +export function getImageUrl(person, size) { + return "https://i.imgur.com/" + person.imageId + size + ".jpg"; +} +``` + +```css +.avatar { + margin: 20px; + border-radius: 50%; +} +``` + +Props let you encapsulate logic like this inside the `Avatar` component (and change it later if needed) so that everyone can use the `<Avatar>` component without thinking about how the images are requested and resized. + +</Solution> + +#### Passing JSX in a `children` prop + +Extract a `Card` component from the markup below, and use the `children` prop to pass different JSX to it: + +```js +export default function Profile() { + return ( + <div> + <div className="card"> + <div className="card-content"> + <h1>Photo</h1> + <img + className="avatar" + src="https://i.imgur.com/OKS67lhm.jpg" + alt="Aklilu Lemma" + width={70} + height={70} + /> + </div> + </div> + <div className="card"> + <div className="card-content"> + <h1>About</h1> + <p> + Aklilu Lemma was a distinguished Ethiopian scientist who + discovered a natural treatment to schistosomiasis. + </p> + </div> + </div> + </div> + ); +} +``` + +```css +.card { + width: fit-content; + margin: 20px; + padding: 20px; + border: 1px solid #aaa; + border-radius: 20px; + background: #fff; +} +.card-content { + text-align: center; +} +.avatar { + margin: 10px; + border-radius: 50%; +} +h1 { + margin: 5px; + padding: 0; + font-size: 24px; +} +``` + +<Hint> + +Any JSX you put inside of a component's tag will be passed as the `children` prop to that component. + +</Hint> + +<Solution> + +This is how you can use the `Card` component in both places: + +```js +function Card({ children }) { + return ( + <div className="card"> + <div className="card-content">{children}</div> + </div> + ); +} + +export default function Profile() { + return ( + <div> + <Card> + <h1>Photo</h1> + <img + className="avatar" + src="https://i.imgur.com/OKS67lhm.jpg" + alt="Aklilu Lemma" + width={100} + height={100} + /> + </Card> + <Card> + <h1>About</h1> + <p> + Aklilu Lemma was a distinguished Ethiopian scientist who + discovered a natural treatment to schistosomiasis. + </p> + </Card> + </div> + ); +} +``` + +```css +.card { + width: fit-content; + margin: 20px; + padding: 20px; + border: 1px solid #aaa; + border-radius: 20px; + background: #fff; +} +.card-content { + text-align: center; +} +.avatar { + margin: 10px; + border-radius: 50%; +} +h1 { + margin: 5px; + padding: 0; + font-size: 24px; +} +``` + +You can also make `title` a separate prop if you want every `Card` to always have a title: + +```js +function Card({ children, title }) { + return ( + <div className="card"> + <div className="card-content"> + <h1>{title}</h1> + {children} + </div> + </div> + ); +} + +export default function Profile() { + return ( + <div> + <Card title="Photo"> + <img + className="avatar" + src="https://i.imgur.com/OKS67lhm.jpg" + alt="Aklilu Lemma" + width={100} + height={100} + /> + </Card> + <Card title="About"> + <p> + Aklilu Lemma was a distinguished Ethiopian scientist who + discovered a natural treatment to schistosomiasis. + </p> + </Card> + </div> + ); +} +``` + +```css +.card { + width: fit-content; + margin: 20px; + padding: 20px; + border: 1px solid #aaa; + border-radius: 20px; + background: #fff; +} +.card-content { + text-align: center; +} +.avatar { + margin: 10px; + border-radius: 50%; +} +h1 { + margin: 5px; + padding: 0; + font-size: 24px; +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/preserving-and-resetting-state.md b/docs/src/learn/preserving-and-resetting-state.md new file mode 100644 index 000000000..4a85e980a --- /dev/null +++ b/docs/src/learn/preserving-and-resetting-state.md @@ -0,0 +1,2023 @@ +## Overview + +<p class="intro" markdown> + +State is isolated between components. React keeps track of which state belongs to which component based on their place in the UI tree. You can control when to preserve state and when to reset it between re-renders. + +</p> + +!!! summary "You will learn" + + - How React "sees" component structures + - When React chooses to preserve or reset the state + - How to force React to reset component's state + - How keys and types affect whether the state is preserved + +## The UI tree + +Browsers use many tree structures to model UI. The [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model/Introduction) represents HTML elements, the [CSSOM](https://developer.mozilla.org/docs/Web/API/CSS_Object_Model) does the same for CSS. There's even an [Accessibility tree](https://developer.mozilla.org/docs/Glossary/Accessibility_tree)! + +React also uses tree structures to manage and model the UI you make. React makes **UI trees** from your JSX. Then React DOM updates the browser DOM elements to match that UI tree. (React Native translates these trees into elements specific to mobile platforms.) + +<!-- TODO: Diagram --> + +## State is tied to a position in the tree + +When you give a component state, you might think the state "lives" inside the component. But the state is actually held inside React. React associates each piece of state it's holding with the correct component by where that component sits in the UI tree. + +Here, there is only one `<Counter />` JSX tag, but it's rendered at two different positions: + +```js +import { useState } from "react"; + +export default function App() { + const counter = <Counter />; + return ( + <div> + {counter} + {counter} + </div> + ); +} + +function Counter() { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +label { + display: block; + clear: both; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.hover { + background: #ffffd8; +} +``` + +Here's how these look as a tree: + +<!-- TODO: Diagram --> + +**These are two separate counters because each is rendered at its own position in the tree.** You don't usually have to think about these positions to use React, but it can be useful to understand how it works. + +In React, each component on the screen has fully isolated state. For example, if you render two `Counter` components side by side, each of them will get its own, independent, `score` and `hover` states. + +Try clicking both counters and notice they don't affect each other: + +```js +import { useState } from "react"; + +export default function App() { + return ( + <div> + <Counter /> + <Counter /> + </div> + ); +} + +function Counter() { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.hover { + background: #ffffd8; +} +``` + +As you can see, when one counter is updated, only the state for that component is updated: + +<!-- TODO: Diagram --> + +React will keep the state around for as long as you render the same component at the same position. To see this, increment both counters, then remove the second component by unchecking "Render the second counter" checkbox, and then add it back by ticking it again: + +```js +import { useState } from "react"; + +export default function App() { + const [showB, setShowB] = useState(true); + return ( + <div> + <Counter /> + {showB && <Counter />} + <label> + <input + type="checkbox" + checked={showB} + onChange={(e) => { + setShowB(e.target.checked); + }} + /> + Render the second counter + </label> + </div> + ); +} + +function Counter() { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +label { + display: block; + clear: both; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.hover { + background: #ffffd8; +} +``` + +Notice how the moment you stop rendering the second counter, its state disappears completely. That's because when React removes a component, it destroys its state. + +<!-- TODO: Diagram --> + +When you tick "Render the second counter", a second `Counter` and its state are initialized from scratch (`score = 0`) and added to the DOM. + +<!-- TODO: Diagram --> + +**React preserves a component's state for as long as it's being rendered at its position in the UI tree.** If it gets removed, or a different component gets rendered at the same position, React discards its state. + +## Same component at the same position preserves state + +In this example, there are two different `<Counter />` tags: + +```js +import { useState } from "react"; + +export default function App() { + const [isFancy, setIsFancy] = useState(false); + return ( + <div> + {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />} + <label> + <input + type="checkbox" + checked={isFancy} + onChange={(e) => { + setIsFancy(e.target.checked); + }} + /> + Use fancy styling + </label> + </div> + ); +} + +function Counter({ isFancy }) { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + if (isFancy) { + className += " fancy"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +label { + display: block; + clear: both; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.fancy { + border: 5px solid gold; + color: #ff6767; +} + +.hover { + background: #ffffd8; +} +``` + +When you tick or clear the checkbox, the counter state does not get reset. Whether `isFancy` is `true` or `false`, you always have a `<Counter />` as the first child of the `div` returned from the root `App` component: + +<!-- TODO: Diagram --> + +It's the same component at the same position, so from React's perspective, it's the same counter. + +<Pitfall> + +Remember that **it's the position in the UI tree--not in the JSX markup--that matters to React!** This component has two `return` clauses with different `<Counter />` JSX tags inside and outside the `if`: + +```js +import { useState } from "react"; + +export default function App() { + const [isFancy, setIsFancy] = useState(false); + if (isFancy) { + return ( + <div> + <Counter isFancy={true} /> + <label> + <input + type="checkbox" + checked={isFancy} + onChange={(e) => { + setIsFancy(e.target.checked); + }} + /> + Use fancy styling + </label> + </div> + ); + } + return ( + <div> + <Counter isFancy={false} /> + <label> + <input + type="checkbox" + checked={isFancy} + onChange={(e) => { + setIsFancy(e.target.checked); + }} + /> + Use fancy styling + </label> + </div> + ); +} + +function Counter({ isFancy }) { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + if (isFancy) { + className += " fancy"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +label { + display: block; + clear: both; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.fancy { + border: 5px solid gold; + color: #ff6767; +} + +.hover { + background: #ffffd8; +} +``` + +You might expect the state to reset when you tick checkbox, but it doesn't! This is because **both of these `<Counter />` tags are rendered at the same position.** React doesn't know where you place the conditions in your function. All it "sees" is the tree you return. + +In both cases, the `App` component returns a `<div>` with `<Counter />` as a first child. To React, these two counters have the same "address": the first child of the first child of the root. This is how React matches them up between the previous and next renders, regardless of how you structure your logic. + +</Pitfall> + +## Different components at the same position reset state + +In this example, ticking the checkbox will replace `<Counter>` with a `<p>`: + +```js +import { useState } from "react"; + +export default function App() { + const [isPaused, setIsPaused] = useState(false); + return ( + <div> + {isPaused ? <p>See you later!</p> : <Counter />} + <label> + <input + type="checkbox" + checked={isPaused} + onChange={(e) => { + setIsPaused(e.target.checked); + }} + /> + Take a break + </label> + </div> + ); +} + +function Counter() { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +label { + display: block; + clear: both; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.hover { + background: #ffffd8; +} +``` + +Here, you switch between _different_ component types at the same position. Initially, the first child of the `<div>` contained a `Counter`. But when you swapped in a `p`, React removed the `Counter` from the UI tree and destroyed its state. + +<!-- TODO: Diagram --> + +<!-- TODO: Diagram --> + +Also, **when you render a different component in the same position, it resets the state of its entire subtree.** To see how this works, increment the counter and then tick the checkbox: + +```js +import { useState } from "react"; + +export default function App() { + const [isFancy, setIsFancy] = useState(false); + return ( + <div> + {isFancy ? ( + <div> + <Counter isFancy={true} /> + </div> + ) : ( + <section> + <Counter isFancy={false} /> + </section> + )} + <label> + <input + type="checkbox" + checked={isFancy} + onChange={(e) => { + setIsFancy(e.target.checked); + }} + /> + Use fancy styling + </label> + </div> + ); +} + +function Counter({ isFancy }) { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + if (isFancy) { + className += " fancy"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1>{score}</h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +label { + display: block; + clear: both; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; + float: left; +} + +.fancy { + border: 5px solid gold; + color: #ff6767; +} + +.hover { + background: #ffffd8; +} +``` + +The counter state gets reset when you click the checkbox. Although you render a `Counter`, the first child of the `div` changes from a `div` to a `section`. When the child `div` was removed from the DOM, the whole tree below it (including the `Counter` and its state) was destroyed as well. + +<!-- TODO: Diagram --> + +<!-- TODO: Diagram --> + +As a rule of thumb, **if you want to preserve the state between re-renders, the structure of your tree needs to "match up"** from one render to another. If the structure is different, the state gets destroyed because React destroys state when it removes a component from the tree. + +<Pitfall> + +This is why you should not nest component function definitions. + +Here, the `MyTextField` component function is defined _inside_ `MyComponent`: + +```js +import { useState } from "react"; + +export default function MyComponent() { + const [counter, setCounter] = useState(0); + + function MyTextField() { + const [text, setText] = useState(""); + + return <input value={text} onChange={(e) => setText(e.target.value)} />; + } + + return ( + <> + <MyTextField /> + <button + on_click={() => { + setCounter(counter + 1); + }} + > + Clicked {counter} times + </button> + </> + ); +} +``` + +Every time you click the button, the input state disappears! This is because a _different_ `MyTextField` function is created for every render of `MyComponent`. You're rendering a _different_ component in the same position, so React resets all state below. This leads to bugs and performance problems. To avoid this problem, **always declare component functions at the top level, and don't nest their definitions.** + +</Pitfall> + +## Resetting state at the same position + +By default, React preserves state of a component while it stays at the same position. Usually, this is exactly what you want, so it makes sense as the default behavior. But sometimes, you may want to reset a component's state. Consider this app that lets two players keep track of their scores during each turn: + +```js +import { useState } from "react"; + +export default function Scoreboard() { + const [isPlayerA, setIsPlayerA] = useState(true); + return ( + <div> + {isPlayerA ? ( + <Counter person="Taylor" /> + ) : ( + <Counter person="Sarah" /> + )} + <button + on_click={() => { + setIsPlayerA(!isPlayerA); + }} + > + Next player! + </button> + </div> + ); +} + +function Counter({ person }) { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1> + {person}'s score: {score} + </h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +h1 { + font-size: 18px; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; +} + +.hover { + background: #ffffd8; +} +``` + +Currently, when you change the player, the score is preserved. The two `Counter`s appear in the same position, so React sees them as _the same_ `Counter` whose `person` prop has changed. + +But conceptually, in this app they should be two separate counters. They might appear in the same place in the UI, but one is a counter for Taylor, and another is a counter for Sarah. + +There are two ways to reset state when switching between them: + +1. Render components in different positions +2. Give each component an explicit identity with `key` + +### Option 1: Rendering a component in different positions + +If you want these two `Counter`s to be independent, you can render them in two different positions: + +```js +import { useState } from "react"; + +export default function Scoreboard() { + const [isPlayerA, setIsPlayerA] = useState(true); + return ( + <div> + {isPlayerA && <Counter person="Taylor" />} + {!isPlayerA && <Counter person="Sarah" />} + <button + on_click={() => { + setIsPlayerA(!isPlayerA); + }} + > + Next player! + </button> + </div> + ); +} + +function Counter({ person }) { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1> + {person}'s score: {score} + </h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +h1 { + font-size: 18px; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; +} + +.hover { + background: #ffffd8; +} +``` + +- Initially, `isPlayerA` is `true`. So the first position contains `Counter` state, and the second one is empty. +- When you click the "Next player" button the first position clears but the second one now contains a `Counter`. + +<!-- TODO: Diagram --> + +Each `Counter`'s state gets destroyed each time its removed from the DOM. This is why they reset every time you click the button. + +This solution is convenient when you only have a few independent components rendered in the same place. In this example, you only have two, so it's not a hassle to render both separately in the JSX. + +### Option 2: Resetting state with a key + +There is also another, more generic, way to reset a component's state. + +You might have seen `key`s when [rendering lists.](/learn/rendering-lists#keeping-list-items-in-order-with-key) Keys aren't just for lists! You can use keys to make React distinguish between any components. By default, React uses order within the parent ("first counter", "second counter") to discern between components. But keys let you tell React that this is not just a _first_ counter, or a _second_ counter, but a specific counter--for example, _Taylor's_ counter. This way, React will know _Taylor's_ counter wherever it appears in the tree! + +In this example, the two `<Counter />`s don't share state even though they appear in the same place in JSX: + +```js +import { useState } from "react"; + +export default function Scoreboard() { + const [isPlayerA, setIsPlayerA] = useState(true); + return ( + <div> + {isPlayerA ? ( + <Counter key="Taylor" person="Taylor" /> + ) : ( + <Counter key="Sarah" person="Sarah" /> + )} + <button + on_click={() => { + setIsPlayerA(!isPlayerA); + }} + > + Next player! + </button> + </div> + ); +} + +function Counter({ person }) { + const [score, setScore] = useState(0); + const [hover, setHover] = useState(false); + + let className = "counter"; + if (hover) { + className += " hover"; + } + + return ( + <div + className={className} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)} + > + <h1> + {person}'s score: {score} + </h1> + <button on_click={() => setScore(score + 1)}>Add one</button> + </div> + ); +} +``` + +```css +h1 { + font-size: 18px; +} + +.counter { + width: 100px; + text-align: center; + border: 1px solid gray; + border-radius: 4px; + padding: 20px; + margin: 0 20px 20px 0; +} + +.hover { + background: #ffffd8; +} +``` + +Switching between Taylor and Sarah does not preserve the state. This is because **you gave them different `key`s:** + +```js +{ + isPlayerA ? ( + <Counter key="Taylor" person="Taylor" /> + ) : ( + <Counter key="Sarah" person="Sarah" /> + ); +} +``` + +Specifying a `key` tells React to use the `key` itself as part of the position, instead of their order within the parent. This is why, even though you render them in the same place in JSX, React sees them as two different counters, and so they will never share state. Every time a counter appears on the screen, its state is created. Every time it is removed, its state is destroyed. Toggling between them resets their state over and over. + +<Note> + +Remember that keys are not globally unique. They only specify the position _within the parent_. + +</Note> + +### Resetting a form with a key + +Resetting state with a key is particularly useful when dealing with forms. + +In this chat app, the `<Chat>` component contains the text input state: + +```js +import { useState } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; + +export default function Messenger() { + const [to, setTo] = useState(contacts[0]); + return ( + <div> + <ContactList + contacts={contacts} + selectedContact={to} + onSelect={(contact) => setTo(contact)} + /> + <Chat contact={to} /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export default function ContactList({ selectedContact, contacts, onSelect }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + onSelect(contact); + }} + > + {contact.name} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact }) { + const [text, setText] = useState(""); + return ( + <section className="chat"> + <textarea + value={text} + placeholder={"Chat to " + contact.name} + onChange={(e) => setText(e.target.value)} + /> + <br /> + <button>Send to {contact.email}</button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +Try entering something into the input, and then press "Alice" or "Bob" to choose a different recipient. You will notice that the input state is preserved because the `<Chat>` is rendered at the same position in the tree. + +**In many apps, this may be the desired behavior, but not in a chat app!** You don't want to let the user send a message they already typed to a wrong person due to an accidental click. To fix it, add a `key`: + +```js +<Chat key={to.id} contact={to} /> +``` + +This ensures that when you select a different recipient, the `Chat` component will be recreated from scratch, including any state in the tree below it. React will also re-create the DOM elements instead of reusing them. + +Now switching the recipient always clears the text field: + +```js +import { useState } from "react"; +import Chat from "./Chat.js"; +import ContactList from "./ContactList.js"; + +export default function Messenger() { + const [to, setTo] = useState(contacts[0]); + return ( + <div> + <ContactList + contacts={contacts} + selectedContact={to} + onSelect={(contact) => setTo(contact)} + /> + <Chat key={to.id} contact={to} /> + </div> + ); +} + +const contacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export default function ContactList({ selectedContact, contacts, onSelect }) { + return ( + <section className="contact-list"> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + onSelect(contact); + }} + > + {contact.name} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Chat({ contact }) { + const [text, setText] = useState(""); + return ( + <section className="chat"> + <textarea + value={text} + placeholder={"Chat to " + contact.name} + onChange={(e) => setText(e.target.value)} + /> + <br /> + <button>Send to {contact.email}</button> + </section> + ); +} +``` + +```css +.chat, +.contact-list { + float: left; + margin-bottom: 20px; +} +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li button { + width: 100px; + padding: 10px; + margin-right: 10px; +} +textarea { + height: 150px; +} +``` + +<DeepDive> + +#### Preserving state for removed components + +In a real chat app, you'd probably want to recover the input state when the user selects the previous recipient again. There are a few ways to keep the state "alive" for a component that's no longer visible: + +- You could render _all_ chats instead of just the current one, but hide all the others with CSS. The chats would not get removed from the tree, so their local state would be preserved. This solution works great for simple UIs. But it can get very slow if the hidden trees are large and contain a lot of DOM nodes. +- You could [lift the state up](/learn/sharing-state-between-components) and hold the pending message for each recipient in the parent component. This way, when the child components get removed, it doesn't matter, because it's the parent that keeps the important information. This is the most common solution. +- You might also use a different source in addition to React state. For example, you probably want a message draft to persist even if the user accidentally closes the page. To implement this, you could have the `Chat` component initialize its state by reading from the [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage), and save the drafts there too. + +No matter which strategy you pick, a chat _with Alice_ is conceptually distinct from a chat _with Bob_, so it makes sense to give a `key` to the `<Chat>` tree based on the current recipient. + +</DeepDive> + +<Recap> + +- React keeps state for as long as the same component is rendered at the same position. +- State is not kept in JSX tags. It's associated with the tree position in which you put that JSX. +- You can force a subtree to reset its state by giving it a different key. +- Don't nest component definitions, or you'll reset state by accident. + +</Recap> + +<Challenges> + +#### Fix disappearing input text + +This example shows a message when you press the button. However, pressing the button also accidentally resets the input. Why does this happen? Fix it so that pressing the button does not reset the input text. + +```js +import { useState } from "react"; + +export default function App() { + const [showHint, setShowHint] = useState(false); + if (showHint) { + return ( + <div> + <p> + <i>Hint: Your favorite city?</i> + </p> + <Form /> + <button + on_click={() => { + setShowHint(false); + }} + > + Hide hint + </button> + </div> + ); + } + return ( + <div> + <Form /> + <button + on_click={() => { + setShowHint(true); + }} + > + Show hint + </button> + </div> + ); +} + +function Form() { + const [text, setText] = useState(""); + return <textarea value={text} onChange={(e) => setText(e.target.value)} />; +} +``` + +```css +textarea { + display: block; + margin: 10px 0; +} +``` + +<Solution> + +The problem is that `Form` is rendered in different positions. In the `if` branch, it is the second child of the `<div>`, but in the `else` branch, it is the first child. Therefore, the component type in each position changes. The first position changes between holding a `p` and a `Form`, while the second position changes between holding a `Form` and a `button`. React resets the state every time the component type changes. + +The easiest solution is to unify the branches so that `Form` always renders in the same position: + +```js +import { useState } from "react"; + +export default function App() { + const [showHint, setShowHint] = useState(false); + return ( + <div> + {showHint && ( + <p> + <i>Hint: Your favorite city?</i> + </p> + )} + <Form /> + {showHint ? ( + <button + on_click={() => { + setShowHint(false); + }} + > + Hide hint + </button> + ) : ( + <button + on_click={() => { + setShowHint(true); + }} + > + Show hint + </button> + )} + </div> + ); +} + +function Form() { + const [text, setText] = useState(""); + return <textarea value={text} onChange={(e) => setText(e.target.value)} />; +} +``` + +```css +textarea { + display: block; + margin: 10px 0; +} +``` + +Technically, you could also add `null` before `<Form />` in the `else` branch to match the `if` branch structure: + +```js +import { useState } from "react"; + +export default function App() { + const [showHint, setShowHint] = useState(false); + if (showHint) { + return ( + <div> + <p> + <i>Hint: Your favorite city?</i> + </p> + <Form /> + <button + on_click={() => { + setShowHint(false); + }} + > + Hide hint + </button> + </div> + ); + } + return ( + <div> + {null} + <Form /> + <button + on_click={() => { + setShowHint(true); + }} + > + Show hint + </button> + </div> + ); +} + +function Form() { + const [text, setText] = useState(""); + return <textarea value={text} onChange={(e) => setText(e.target.value)} />; +} +``` + +```css +textarea { + display: block; + margin: 10px 0; +} +``` + +This way, `Form` is always the second child, so it stays in the same position and keeps its state. But this approach is much less obvious and introduces a risk that someone else will remove that `null`. + +</Solution> + +#### Swap two form fields + +This form lets you enter first and last name. It also has a checkbox controlling which field goes first. When you tick the checkbox, the "Last name" field will appear before the "First name" field. + +It almost works, but there is a bug. If you fill in the "First name" input and tick the checkbox, the text will stay in the first input (which is now "Last name"). Fix it so that the input text _also_ moves when you reverse the order. + +<Hint> + +It seems like for these fields, their position within the parent is not enough. Is there some way to tell React how to match up the state between re-renders? + +</Hint> + +```js +import { useState } from "react"; + +export default function App() { + const [reverse, setReverse] = useState(false); + let checkbox = ( + <label> + <input + type="checkbox" + checked={reverse} + onChange={(e) => setReverse(e.target.checked)} + /> + Reverse order + </label> + ); + if (reverse) { + return ( + <> + <Field label="Last name" /> + <Field label="First name" /> + {checkbox} + </> + ); + } else { + return ( + <> + <Field label="First name" /> + <Field label="Last name" /> + {checkbox} + </> + ); + } +} + +function Field({ label }) { + const [text, setText] = useState(""); + return ( + <label> + {label}:{" "} + <input + type="text" + value={text} + placeholder={label} + onChange={(e) => setText(e.target.value)} + /> + </label> + ); +} +``` + +```css +label { + display: block; + margin: 10px 0; +} +``` + +<Solution> + +Give a `key` to both `<Field>` components in both `if` and `else` branches. This tells React how to "match up" the correct state for either `<Field>` even if their order within the parent changes: + +```js +import { useState } from "react"; + +export default function App() { + const [reverse, setReverse] = useState(false); + let checkbox = ( + <label> + <input + type="checkbox" + checked={reverse} + onChange={(e) => setReverse(e.target.checked)} + /> + Reverse order + </label> + ); + if (reverse) { + return ( + <> + <Field key="lastName" label="Last name" /> + <Field key="firstName" label="First name" /> + {checkbox} + </> + ); + } else { + return ( + <> + <Field key="firstName" label="First name" /> + <Field key="lastName" label="Last name" /> + {checkbox} + </> + ); + } +} + +function Field({ label }) { + const [text, setText] = useState(""); + return ( + <label> + {label}:{" "} + <input + type="text" + value={text} + placeholder={label} + onChange={(e) => setText(e.target.value)} + /> + </label> + ); +} +``` + +```css +label { + display: block; + margin: 10px 0; +} +``` + +</Solution> + +#### Reset a detail form + +This is an editable contact list. You can edit the selected contact's details and then either press "Save" to update it, or "Reset" to undo your changes. + +When you select a different contact (for example, Alice), the state updates but the form keeps showing the previous contact's details. Fix it so that the form gets reset when the selected contact changes. + +```js +import { useState } from "react"; +import ContactList from "./ContactList.js"; +import EditContact from "./EditContact.js"; + +export default function ContactManager() { + const [contacts, setContacts] = useState(initialContacts); + const [selectedId, setSelectedId] = useState(0); + const selectedContact = contacts.find((c) => c.id === selectedId); + + function handleSave(updatedData) { + const nextContacts = contacts.map((c) => { + if (c.id === updatedData.id) { + return updatedData; + } else { + return c; + } + }); + setContacts(nextContacts); + } + + return ( + <div> + <ContactList + contacts={contacts} + selectedId={selectedId} + onSelect={(id) => setSelectedId(id)} + /> + <hr /> + <EditContact initialData={selectedContact} onSave={handleSave} /> + </div> + ); +} + +const initialContacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export default function ContactList({ contacts, selectedId, onSelect }) { + return ( + <section> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + onSelect(contact.id); + }} + > + {contact.id === selectedId ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function EditContact({ initialData, onSave }) { + const [name, setName] = useState(initialData.name); + const [email, setEmail] = useState(initialData.email); + return ( + <section> + <label> + Name:{" "} + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </label> + <label> + Email:{" "} + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </label> + <button + on_click={() => { + const updatedData = { + id: initialData.id, + name: name, + email: email, + }; + onSave(updatedData); + }} + > + Save + </button> + <button + on_click={() => { + setName(initialData.name); + setEmail(initialData.email); + }} + > + Reset + </button> + </section> + ); +} +``` + +```css +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li { + display: inline-block; +} +li button { + padding: 10px; +} +label { + display: block; + margin: 10px 0; +} +button { + margin-right: 10px; + margin-bottom: 10px; +} +``` + +<Solution> + +Give `key={selectedId}` to the `EditContact` component. This way, switching between different contacts will reset the form: + +```js +import { useState } from "react"; +import ContactList from "./ContactList.js"; +import EditContact from "./EditContact.js"; + +export default function ContactManager() { + const [contacts, setContacts] = useState(initialContacts); + const [selectedId, setSelectedId] = useState(0); + const selectedContact = contacts.find((c) => c.id === selectedId); + + function handleSave(updatedData) { + const nextContacts = contacts.map((c) => { + if (c.id === updatedData.id) { + return updatedData; + } else { + return c; + } + }); + setContacts(nextContacts); + } + + return ( + <div> + <ContactList + contacts={contacts} + selectedId={selectedId} + onSelect={(id) => setSelectedId(id)} + /> + <hr /> + <EditContact + key={selectedId} + initialData={selectedContact} + onSave={handleSave} + /> + </div> + ); +} + +const initialContacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export default function ContactList({ contacts, selectedId, onSelect }) { + return ( + <section> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + onSelect(contact.id); + }} + > + {contact.id === selectedId ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function EditContact({ initialData, onSave }) { + const [name, setName] = useState(initialData.name); + const [email, setEmail] = useState(initialData.email); + return ( + <section> + <label> + Name:{" "} + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </label> + <label> + Email:{" "} + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </label> + <button + on_click={() => { + const updatedData = { + id: initialData.id, + name: name, + email: email, + }; + onSave(updatedData); + }} + > + Save + </button> + <button + on_click={() => { + setName(initialData.name); + setEmail(initialData.email); + }} + > + Reset + </button> + </section> + ); +} +``` + +```css +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li { + display: inline-block; +} +li button { + padding: 10px; +} +label { + display: block; + margin: 10px 0; +} +button { + margin-right: 10px; + margin-bottom: 10px; +} +``` + +</Solution> + +#### Clear an image while it's loading + +When you press "Next", the browser starts loading the next image. However, because it's displayed in the same `<img>` tag, by default you would still see the previous image until the next one loads. This may be undesirable if it's important for the text to always match the image. Change it so that the moment you press "Next", the previous image immediately clears. + +<Hint> + +Is there a way to tell React to re-create the DOM instead of reusing it? + +</Hint> + +```js +import { useState } from "react"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + const hasNext = index < images.length - 1; + + function handleClick() { + if (hasNext) { + setIndex(index + 1); + } else { + setIndex(0); + } + } + + let image = images[index]; + return ( + <> + <button on_click={handleClick}>Next</button> + <h3> + Image {index + 1} of {images.length} + </h3> + <img src={image.src} /> + <p>{image.place}</p> + </> + ); +} + +let images = [ + { + place: "Penang, Malaysia", + src: "https://i.imgur.com/FJeJR8M.jpg", + }, + { + place: "Lisbon, Portugal", + src: "https://i.imgur.com/dB2LRbj.jpg", + }, + { + place: "Bilbao, Spain", + src: "https://i.imgur.com/z08o2TS.jpg", + }, + { + place: "Valparaíso, Chile", + src: "https://i.imgur.com/Y3utgTi.jpg", + }, + { + place: "Schwyz, Switzerland", + src: "https://i.imgur.com/JBbMpWY.jpg", + }, + { + place: "Prague, Czechia", + src: "https://i.imgur.com/QwUKKmF.jpg", + }, + { + place: "Ljubljana, Slovenia", + src: "https://i.imgur.com/3aIiwfm.jpg", + }, +]; +``` + +```css +img { + width: 150px; + height: 150px; +} +``` + +<Solution> + +You can provide a `key` to the `<img>` tag. When that `key` changes, React will re-create the `<img>` DOM node from scratch. This causes a brief flash when each image loads, so it's not something you'd want to do for every image in your app. But it makes sense if you want to ensure the image always matches the text. + +```js +import { useState } from "react"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + const hasNext = index < images.length - 1; + + function handleClick() { + if (hasNext) { + setIndex(index + 1); + } else { + setIndex(0); + } + } + + let image = images[index]; + return ( + <> + <button on_click={handleClick}>Next</button> + <h3> + Image {index + 1} of {images.length} + </h3> + <img key={image.src} src={image.src} /> + <p>{image.place}</p> + </> + ); +} + +let images = [ + { + place: "Penang, Malaysia", + src: "https://i.imgur.com/FJeJR8M.jpg", + }, + { + place: "Lisbon, Portugal", + src: "https://i.imgur.com/dB2LRbj.jpg", + }, + { + place: "Bilbao, Spain", + src: "https://i.imgur.com/z08o2TS.jpg", + }, + { + place: "Valparaíso, Chile", + src: "https://i.imgur.com/Y3utgTi.jpg", + }, + { + place: "Schwyz, Switzerland", + src: "https://i.imgur.com/JBbMpWY.jpg", + }, + { + place: "Prague, Czechia", + src: "https://i.imgur.com/QwUKKmF.jpg", + }, + { + place: "Ljubljana, Slovenia", + src: "https://i.imgur.com/3aIiwfm.jpg", + }, +]; +``` + +```css +img { + width: 150px; + height: 150px; +} +``` + +</Solution> + +#### Fix misplaced state in the list + +In this list, each `Contact` has state that determines whether "Show email" has been pressed for it. Press "Show email" for Alice, and then tick the "Show in reverse order" checkbox. You will notice that it's _Taylor's_ email that is expanded now, but Alice's--which has moved to the bottom--appears collapsed. + +Fix it so that the expanded state is associated with each contact, regardless of the chosen ordering. + +```js +import { useState } from "react"; +import Contact from "./Contact.js"; + +export default function ContactList() { + const [reverse, setReverse] = useState(false); + + const displayedContacts = [...contacts]; + if (reverse) { + displayedContacts.reverse(); + } + + return ( + <> + <label> + <input + type="checkbox" + value={reverse} + onChange={(e) => { + setReverse(e.target.checked); + }} + />{" "} + Show in reverse order + </label> + <ul> + {displayedContacts.map((contact, i) => ( + <li key={i}> + <Contact contact={contact} /> + </li> + ))} + </ul> + </> + ); +} + +const contacts = [ + { id: 0, name: "Alice", email: "alice@mail.com" }, + { id: 1, name: "Bob", email: "bob@mail.com" }, + { id: 2, name: "Taylor", email: "taylor@mail.com" }, +]; +``` + +```js +import { useState } from "react"; + +export default function Contact({ contact }) { + const [expanded, setExpanded] = useState(false); + return ( + <> + <p> + <b>{contact.name}</b> + </p> + {expanded && ( + <p> + <i>{contact.email}</i> + </p> + )} + <button + on_click={() => { + setExpanded(!expanded); + }} + > + {expanded ? "Hide" : "Show"} email + </button> + </> + ); +} +``` + +```css +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li { + margin-bottom: 20px; +} +label { + display: block; + margin: 10px 0; +} +button { + margin-right: 10px; + margin-bottom: 10px; +} +``` + +<Solution> + +The problem is that this example was using index as a `key`: + +```js +{displayedContacts.map((contact, i) => + <li key={i}> +``` + +However, you want the state to be associated with _each particular contact_. + +Using the contact ID as a `key` instead fixes the issue: + +```js +import { useState } from "react"; +import Contact from "./Contact.js"; + +export default function ContactList() { + const [reverse, setReverse] = useState(false); + + const displayedContacts = [...contacts]; + if (reverse) { + displayedContacts.reverse(); + } + + return ( + <> + <label> + <input + type="checkbox" + value={reverse} + onChange={(e) => { + setReverse(e.target.checked); + }} + />{" "} + Show in reverse order + </label> + <ul> + {displayedContacts.map((contact) => ( + <li key={contact.id}> + <Contact contact={contact} /> + </li> + ))} + </ul> + </> + ); +} + +const contacts = [ + { id: 0, name: "Alice", email: "alice@mail.com" }, + { id: 1, name: "Bob", email: "bob@mail.com" }, + { id: 2, name: "Taylor", email: "taylor@mail.com" }, +]; +``` + +```js +import { useState } from "react"; + +export default function Contact({ contact }) { + const [expanded, setExpanded] = useState(false); + return ( + <> + <p> + <b>{contact.name}</b> + </p> + {expanded && ( + <p> + <i>{contact.email}</i> + </p> + )} + <button + on_click={() => { + setExpanded(!expanded); + }} + > + {expanded ? "Hide" : "Show"} email + </button> + </> + ); +} +``` + +```css +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li { + margin-bottom: 20px; +} +label { + display: block; + margin: 10px 0; +} +button { + margin-right: 10px; + margin-bottom: 10px; +} +``` + +State is associated with the tree position. A `key` lets you specify a named position instead of relying on order. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/python-in-psx-with-curly-braces.md b/docs/src/learn/python-in-psx-with-curly-braces.md new file mode 100644 index 000000000..22ab27331 --- /dev/null +++ b/docs/src/learn/python-in-psx-with-curly-braces.md @@ -0,0 +1,594 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. + + See [this issue](https://github.com/reactive-python/reactpy/issues/918) for more details. + +<!-- +## Overview + +<p class="intro" markdown> + +JSX lets you write HTML-like markup inside a JavaScript file, keeping rendering logic and content in the same place. Sometimes you will want to add a little JavaScript logic or reference a dynamic property inside that markup. In this situation, you can use curly braces in your JSX to open a window to JavaScript. + +</p> + +!!! summary "You will learn" + + - How to pass strings with quotes + - How to reference a JavaScript variable inside JSX with curly braces + - How to call a JavaScript function inside JSX with curly braces + - How to use a JavaScript object inside JSX with curly braces + +## Passing strings with quotes + +When you want to pass a string attribute to JSX, you put it in single or double quotes: + +```js +export default function Avatar() { + return ( + <img + className="avatar" + src="https://i.imgur.com/7vQD0fPs.jpg" + alt="Gregorio Y. Zara" + /> + ); +} +``` + +```css +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +Here, `"https://i.imgur.com/7vQD0fPs.jpg"` and `"Gregorio Y. Zara"` are being passed as strings. + +But what if you want to dynamically specify the `src` or `alt` text? You could **use a value from JavaScript by replacing `"` and `"` with `{` and `}`**: + +```js +export default function Avatar() { + const avatar = "https://i.imgur.com/7vQD0fPs.jpg"; + const description = "Gregorio Y. Zara"; + return <img className="avatar" src={avatar} alt={description} />; +} +``` + +```css +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +Notice the difference between `className="avatar"`, which specifies an `"avatar"` CSS class name that makes the image round, and `src={avatar}` that reads the value of the JavaScript variable called `avatar`. That's because curly braces let you work with JavaScript right there in your markup! + +## Using curly braces: A window into the JavaScript world + +JSX is a special way of writing JavaScript. That means it’s possible to use JavaScript inside it—with curly braces `{ }`. The example below first declares a name for the scientist, `name`, then embeds it with curly braces inside the `<h1>`: + +```js +export default function TodoList() { + const name = "Gregorio Y. Zara"; + return <h1>{name}'s To Do List</h1>; +} +``` + +Try changing the `name`'s value from `'Gregorio Y. Zara'` to `'Hedy Lamarr'`. See how the list title changes? + +Any JavaScript expression will work between curly braces, including function calls like `formatDate()`: + +```js +const today = new Date(); + +function formatDate(date) { + return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date); +} + +export default function TodoList() { + return <h1>To Do List for {formatDate(today)}</h1>; +} +``` + +### Where to use curly braces + +You can only use curly braces in two ways inside JSX: + +1. **As text** directly inside a JSX tag: `<h1>{name}'s To Do List</h1>` works, but `<{tag}>Gregorio Y. Zara's To Do List</{tag}>` will not. +2. **As attributes** immediately following the `=` sign: `src={avatar}` will read the `avatar` variable, but `src="{avatar}"` will pass the string `"{avatar}"`. + +## Using "double curlies": CSS and other objects in JSX + +In addition to strings, numbers, and other JavaScript expressions, you can even pass objects in JSX. Objects are also denoted with curly braces, like `{ name: "Hedy Lamarr", inventions: 5 }`. Therefore, to pass a JS object in JSX, you must wrap the object in another pair of curly braces: `person={{ name: "Hedy Lamarr", inventions: 5 }}`. + +You may see this with inline CSS styles in JSX. React does not require you to use inline styles (CSS classes work great for most cases). But when you need an inline style, you pass an object to the `style` attribute: + +```js +export default function TodoList() { + return ( + <ul + style={{ + backgroundColor: "black", + color: "pink", + }} + > + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +ul { + padding: 20px 20px 20px 40px; + margin: 0; +} +``` + +Try changing the values of `backgroundColor` and `color`. + +You can really see the JavaScript object inside the curly braces when you write it like this: + +```js +<ul style={ + { + backgroundColor: 'black', + color: 'pink' + } +}> +``` + +The next time you see `{{` and `}}` in JSX, know that it's nothing more than an object inside the JSX curlies! + +<Pitfall> + +Inline `style` properties are written in camelCase. For example, HTML `<ul style="background-color: black">` would be written as `<ul style={{ backgroundColor: 'black' }}>` in your component. + +</Pitfall> + +## More fun with JavaScript objects and curly braces + +You can move several expressions into one object, and reference them in your JSX inside curly braces: + +```js +const person = { + name: "Gregorio Y. Zara", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src="https://i.imgur.com/7vQD0fPs.jpg" + alt="Gregorio Y. Zara" + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +In this example, the `person` JavaScript object contains a `name` string and a `theme` object: + +```js +const person = { + name: "Gregorio Y. Zara", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; +``` + +The component can use these values from `person` like so: + +```js +<div style={person.theme}> + <h1>{person.name}'s Todos</h1> +``` + +JSX is very minimal as a templating language because it lets you organize data and logic using JavaScript. + +<Recap> + +Now you know almost everything about JSX: + +- JSX attributes inside quotes are passed as strings. +- Curly braces let you bring JavaScript logic and variables into your markup. +- They work inside the JSX tag content or immediately after `=` in attributes. +- `{{` and `}}` is not special syntax: it's a JavaScript object tucked inside JSX curly braces. + +</Recap> + +<Challenges> + +#### Fix the mistake + +This code crashes with an error saying `Objects are not valid as a React child`: + +```js +const person = { + name: "Gregorio Y. Zara", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person}'s Todos</h1> + <img + className="avatar" + src="https://i.imgur.com/7vQD0fPs.jpg" + alt="Gregorio Y. Zara" + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +Can you find the problem? + +<Hint>Look for what's inside the curly braces. Are we putting the right thing there?</Hint> + +<Solution> + +This is happening because this example renders _an object itself_ into the markup rather than a string: `<h1>{person}'s Todos</h1>` is trying to render the entire `person` object! Including raw objects as text content throws an error because React doesn't know how you want to display them. + +To fix it, replace `<h1>{person}'s Todos</h1>` with `<h1>{person.name}'s Todos</h1>`: + +```js +const person = { + name: "Gregorio Y. Zara", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src="https://i.imgur.com/7vQD0fPs.jpg" + alt="Gregorio Y. Zara" + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +</Solution> + +#### Extract information into an object + +Extract the image URL into the `person` object. + +```js +const person = { + name: "Gregorio Y. Zara", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src="https://i.imgur.com/7vQD0fPs.jpg" + alt="Gregorio Y. Zara" + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +<Solution> + +Move the image URL into a property called `person.imageUrl` and read it from the `<img>` tag using the curlies: + +```js +const person = { + name: "Gregorio Y. Zara", + imageUrl: "https://i.imgur.com/7vQD0fPs.jpg", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src={person.imageUrl} + alt="Gregorio Y. Zara" + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; + height: 90px; +} +``` + +</Solution> + +#### Write an expression inside JSX curly braces + +In the object below, the full image URL is split into four parts: base URL, `imageId`, `imageSize`, and file extension. + +We want the image URL to combine these attributes together: base URL (always `'https://i.imgur.com/'`), `imageId` (`'7vQD0fP'`), `imageSize` (`'s'`), and file extension (always `'.jpg'`). However, something is wrong with how the `<img>` tag specifies its `src`. + +Can you fix it? + +```js +const baseUrl = "https://i.imgur.com/"; +const person = { + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + imageSize: "s", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src="{baseUrl}{person.imageId}{person.imageSize}.jpg" + alt={person.name} + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; +} +``` + +To check that your fix worked, try changing the value of `imageSize` to `'b'`. The image should resize after your edit. + +<Solution> + +You can write it as `src={baseUrl + person.imageId + person.imageSize + '.jpg'}`. + +1. `{` opens the JavaScript expression +2. `baseUrl + person.imageId + person.imageSize + '.jpg'` produces the correct URL string +3. `}` closes the JavaScript expression + +```js +const baseUrl = "https://i.imgur.com/"; +const person = { + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + imageSize: "s", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src={baseUrl + person.imageId + person.imageSize + ".jpg"} + alt={person.name} + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; +} +``` + +You can also move this expression into a separate function like `getImageUrl` below: + +```js +import { getImageUrl } from "./utils.js"; + +const person = { + name: "Gregorio Y. Zara", + imageId: "7vQD0fP", + imageSize: "s", + theme: { + backgroundColor: "black", + color: "pink", + }, +}; + +export default function TodoList() { + return ( + <div style={person.theme}> + <h1>{person.name}'s Todos</h1> + <img + className="avatar" + src={getImageUrl(person)} + alt={person.name} + /> + <ul> + <li>Improve the videophone</li> + <li>Prepare aeronautics lectures</li> + <li>Work on the alcohol-fuelled engine</li> + </ul> + </div> + ); +} +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + person.imageSize + ".jpg"; +} +``` + +```css +body { + padding: 0; + margin: 0; +} +body > div > div { + padding: 20px; +} +.avatar { + border-radius: 50%; +} +``` + +Variables and functions can help you keep the markup simple! + +</Solution> + +</Challenges> --> diff --git a/docs/src/learn/queueing-a-series-of-state-updates.md b/docs/src/learn/queueing-a-series-of-state-updates.md new file mode 100644 index 000000000..021429428 --- /dev/null +++ b/docs/src/learn/queueing-a-series-of-state-updates.md @@ -0,0 +1,556 @@ +## Overview + +<p class="intro" markdown> + +Setting a state variable will queue another render. But sometimes you might want to perform multiple operations on the value before queueing the next render. To do this, it helps to understand how React batches state updates. + +</p> + +!!! summary "You will learn" + + - What "batching" is and how React uses it to process multiple state updates + - How to apply several updates to the same state variable in a row + +## React batches state updates + +You might expect that clicking the "+3" button will increment the counter three times because it calls `setNumber(number + 1)` three times: + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber(number + 1); + setNumber(number + 1); + setNumber(number + 1); + }} + > + +3 + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +However, as you might recall from the previous section, [each render's state values are fixed](/learn/state-as-a-snapshot#rendering-takes-a-snapshot-in-time), so the value of `number` inside the first render's event handler is always `0`, no matter how many times you call `setNumber(1)`: + +```js +setNumber(0 + 1); +setNumber(0 + 1); +setNumber(0 + 1); +``` + +But there is one other factor at play here. **React waits until _all_ code in the event handlers has run before processing your state updates.** This is why the re-render only happens _after_ all these `setNumber()` calls. + +This might remind you of a waiter taking an order at the restaurant. A waiter doesn't run to the kitchen at the mention of your first dish! Instead, they let you finish your order, let you make changes to it, and even take orders from other people at the table. + +<Illustration src="/images/docs/illustrations/i_react-batching.png" alt="An elegant cursor at a restaurant places and order multiple times with React, playing the part of the waiter. After she calls setState() multiple times, the waiter writes down the last one she requested as her final order." /> + +This lets you update multiple state variables--even from multiple components--without triggering too many [re-renders.](/learn/render-and-commit#re-renders-when-state-updates) But this also means that the UI won't be updated until _after_ your event handler, and any code in it, completes. This behavior, also known as **batching,** makes your React app run much faster. It also avoids dealing with confusing "half-finished" renders where only some of the variables have been updated. + +**React does not batch across _multiple_ intentional events like clicks**--each click is handled separately. Rest assured that React only does batching when it's generally safe to do. This ensures that, for example, if the first button click disables a form, the second click would not submit it again. + +## Updating the same state multiple times before the next render + +It is an uncommon use case, but if you would like to update the same state variable multiple times before the next render, instead of passing the _next state value_ like `setNumber(number + 1)`, you can pass a _function_ that calculates the next state based on the previous one in the queue, like `setNumber(n => n + 1)`. It is a way to tell React to "do something with the state value" instead of just replacing it. + +Try incrementing the counter now: + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber((n) => n + 1); + setNumber((n) => n + 1); + setNumber((n) => n + 1); + }} + > + +3 + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +Here, `n => n + 1` is called an **updater function.** When you pass it to a state setter: + +1. React queues this function to be processed after all the other code in the event handler has run. +2. During the next render, React goes through the queue and gives you the final updated state. + +```js +setNumber((n) => n + 1); +setNumber((n) => n + 1); +setNumber((n) => n + 1); +``` + +Here's how React works through these lines of code while executing the event handler: + +1. `setNumber(n => n + 1)`: `n => n + 1` is a function. React adds it to a queue. +1. `setNumber(n => n + 1)`: `n => n + 1` is a function. React adds it to a queue. +1. `setNumber(n => n + 1)`: `n => n + 1` is a function. React adds it to a queue. + +When you call `useState` during the next render, React goes through the queue. The previous `number` state was `0`, so that's what React passes to the first updater function as the `n` argument. Then React takes the return value of your previous updater function and passes it to the next updater as `n`, and so on: + +| queued update | `n` | returns | +| ------------- | --- | ----------- | +| `n => n + 1` | `0` | `0 + 1 = 1` | +| `n => n + 1` | `1` | `1 + 1 = 2` | +| `n => n + 1` | `2` | `2 + 1 = 3` | + +React stores `3` as the final result and returns it from `useState`. + +This is why clicking "+3" in the above example correctly increments the value by 3. + +### What happens if you update state after replacing it + +What about this event handler? What do you think `number` will be in the next render? + +```js +<button on_click={() => { + setNumber(number + 5); + setNumber(n => n + 1); +}}> +``` + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber(number + 5); + setNumber((n) => n + 1); + }} + > + Increase the number + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +Here's what this event handler tells React to do: + +1. `setNumber(number + 5)`: `number` is `0`, so `setNumber(0 + 5)`. React adds _"replace with `5`"_ to its queue. +2. `setNumber(n => n + 1)`: `n => n + 1` is an updater function. React adds _that function_ to its queue. + +During the next render, React goes through the state queue: + +| queued update | `n` | returns | +| ------------------ | ------------ | ----------- | +| "replace with `5`" | `0` (unused) | `5` | +| `n => n + 1` | `5` | `5 + 1 = 6` | + +React stores `6` as the final result and returns it from `useState`. + +<Note> + +You may have noticed that `setState(5)` actually works like `setState(n => 5)`, but `n` is unused! + +</Note> + +### What happens if you replace state after updating it + +Let's try one more example. What do you think `number` will be in the next render? + +```js +<button on_click={() => { + setNumber(number + 5); + setNumber(n => n + 1); + setNumber(42); +}}> +``` + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber(number + 5); + setNumber((n) => n + 1); + setNumber(42); + }} + > + Increase the number + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +Here's how React works through these lines of code while executing this event handler: + +1. `setNumber(number + 5)`: `number` is `0`, so `setNumber(0 + 5)`. React adds _"replace with `5`"_ to its queue. +2. `setNumber(n => n + 1)`: `n => n + 1` is an updater function. React adds _that function_ to its queue. +3. `setNumber(42)`: React adds _"replace with `42`"_ to its queue. + +During the next render, React goes through the state queue: + +| queued update | `n` | returns | +| ------------------- | ------------ | ----------- | +| "replace with `5`" | `0` (unused) | `5` | +| `n => n + 1` | `5` | `5 + 1 = 6` | +| "replace with `42`" | `6` (unused) | `42` | + +Then React stores `42` as the final result and returns it from `useState`. + +To summarize, here's how you can think of what you're passing to the `setNumber` state setter: + +- **An updater function** (e.g. `n => n + 1`) gets added to the queue. +- **Any other value** (e.g. number `5`) adds "replace with `5`" to the queue, ignoring what's already queued. + +After the event handler completes, React will trigger a re-render. During the re-render, React will process the queue. Updater functions run during rendering, so **updater functions must be [pure](/learn/keeping-components-pure)** and only _return_ the result. Don't try to set state from inside of them or run other side effects. In Strict Mode, React will run each updater function twice (but discard the second result) to help you find mistakes. + +### Naming conventions + +It's common to name the updater function argument by the first letters of the corresponding state variable: + +```js +setEnabled((e) => !e); +setLastName((ln) => ln.reverse()); +setFriendCount((fc) => fc * 2); +``` + +If you prefer more verbose code, another common convention is to repeat the full state variable name, like `setEnabled(enabled => !enabled)`, or to use a prefix like `setEnabled(prevEnabled => !prevEnabled)`. + +<Recap> + +- Setting state does not change the variable in the existing render, but it requests a new render. +- React processes state updates after event handlers have finished running. This is called batching. +- To update some state multiple times in one event, you can use `setNumber(n => n + 1)` updater function. + +</Recap> + +<Challenges> + +#### Fix a request counter + +You're working on an art marketplace app that lets the user submit multiple orders for an art item at the same time. Each time the user presses the "Buy" button, the "Pending" counter should increase by one. After three seconds, the "Pending" counter should decrease, and the "Completed" counter should increase. + +However, the "Pending" counter does not behave as intended. When you press "Buy", it decreases to `-1` (which should not be possible!). And if you click fast twice, both counters seem to behave unpredictably. + +Why does this happen? Fix both counters. + +```js +import { useState } from "react"; + +export default function RequestTracker() { + const [pending, setPending] = useState(0); + const [completed, setCompleted] = useState(0); + + async function handleClick() { + setPending(pending + 1); + await delay(3000); + setPending(pending - 1); + setCompleted(completed + 1); + } + + return ( + <> + <h3>Pending: {pending}</h3> + <h3>Completed: {completed}</h3> + <button on_click={handleClick}>Buy</button> + </> + ); +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} +``` + +<Solution> + +Inside the `handleClick` event handler, the values of `pending` and `completed` correspond to what they were at the time of the click event. For the first render, `pending` was `0`, so `setPending(pending - 1)` becomes `setPending(-1)`, which is wrong. Since you want to _increment_ or _decrement_ the counters, rather than set them to a concrete value determined during the click, you can instead pass the updater functions: + +```js +import { useState } from "react"; + +export default function RequestTracker() { + const [pending, setPending] = useState(0); + const [completed, setCompleted] = useState(0); + + async function handleClick() { + setPending((p) => p + 1); + await delay(3000); + setPending((p) => p - 1); + setCompleted((c) => c + 1); + } + + return ( + <> + <h3>Pending: {pending}</h3> + <h3>Completed: {completed}</h3> + <button on_click={handleClick}>Buy</button> + </> + ); +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} +``` + +This ensures that when you increment or decrement a counter, you do it in relation to its _latest_ state rather than what the state was at the time of the click. + +</Solution> + +#### Implement the state queue yourself + +In this challenge, you will reimplement a tiny part of React from scratch! It's not as hard as it sounds. + +Scroll through the sandbox preview. Notice that it shows **four test cases.** They correspond to the examples you've seen earlier on this page. Your task is to implement the `getFinalState` function so that it returns the correct result for each of those cases. If you implement it correctly, all four tests should pass. + +You will receive two arguments: `baseState` is the initial state (like `0`), and the `queue` is an array which contains a mix of numbers (like `5`) and updater functions (like `n => n + 1`) in the order they were added. + +Your task is to return the final state, just like the tables on this page show! + +<Hint> + +If you're feeling stuck, start with this code structure: + +```js +export function getFinalState(baseState, queue) { + let finalState = baseState; + + for (let update of queue) { + if (typeof update === "function") { + // TODO: apply the updater function + } else { + // TODO: replace the state + } + } + + return finalState; +} +``` + +Fill out the missing lines! + +</Hint> + +```js +export function getFinalState(baseState, queue) { + let finalState = baseState; + + // TODO: do something with the queue... + + return finalState; +} +``` + +```js +import { getFinalState } from "./processQueue.js"; + +function increment(n) { + return n + 1; +} +increment.toString = () => "n => n+1"; + +export default function App() { + return ( + <> + <TestCase baseState={0} queue={[1, 1, 1]} expected={1} /> + <hr /> + <TestCase + baseState={0} + queue={[increment, increment, increment]} + expected={3} + /> + <hr /> + <TestCase baseState={0} queue={[5, increment]} expected={6} /> + <hr /> + <TestCase baseState={0} queue={[5, increment, 42]} expected={42} /> + </> + ); +} + +function TestCase({ baseState, queue, expected }) { + const actual = getFinalState(baseState, queue); + return ( + <> + <p> + Base state: <b>{baseState}</b> + </p> + <p> + Queue: <b>[{queue.join(", ")}]</b> + </p> + <p> + Expected result: <b>{expected}</b> + </p> + <p + style={{ + color: actual === expected ? "green" : "red", + }} + > + Your result: <b>{actual}</b> ( + {actual === expected ? "correct" : "wrong"}) + </p> + </> + ); +} +``` + +<Solution> + +This is the exact algorithm described on this page that React uses to calculate the final state: + +```js +export function getFinalState(baseState, queue) { + let finalState = baseState; + + for (let update of queue) { + if (typeof update === "function") { + // Apply the updater function. + finalState = update(finalState); + } else { + // Replace the next state. + finalState = update; + } + } + + return finalState; +} +``` + +```js +import { getFinalState } from "./processQueue.js"; + +function increment(n) { + return n + 1; +} +increment.toString = () => "n => n+1"; + +export default function App() { + return ( + <> + <TestCase baseState={0} queue={[1, 1, 1]} expected={1} /> + <hr /> + <TestCase + baseState={0} + queue={[increment, increment, increment]} + expected={3} + /> + <hr /> + <TestCase baseState={0} queue={[5, increment]} expected={6} /> + <hr /> + <TestCase baseState={0} queue={[5, increment, 42]} expected={42} /> + </> + ); +} + +function TestCase({ baseState, queue, expected }) { + const actual = getFinalState(baseState, queue); + return ( + <> + <p> + Base state: <b>{baseState}</b> + </p> + <p> + Queue: <b>[{queue.join(", ")}]</b> + </p> + <p> + Expected result: <b>{expected}</b> + </p> + <p + style={{ + color: actual === expected ? "green" : "red", + }} + > + Your result: <b>{actual}</b> ( + {actual === expected ? "correct" : "wrong"}) + </p> + </> + ); +} +``` + +Now you know how this part of React works! + +</Solution> + +</Challenges> diff --git a/docs/src/learn/quick-start.md b/docs/src/learn/quick-start.md new file mode 100644 index 000000000..891612901 --- /dev/null +++ b/docs/src/learn/quick-start.md @@ -0,0 +1,299 @@ +## Overview + +<p class="intro" markdown> + +Welcome to the ReactPy documentation! This page will give you an introduction to the 80% of React concepts that you will use on a daily basis. + +</p> + +!!! summary "You will learn" + + - How to create and nest components + - How to add markup and styles + - How to display data + - How to render conditions and lists + - How to respond to events and update the screen + - How to share data between components + +## Creating and nesting components + +React apps are made out of _components_. A component is a piece of the UI (user interface) that has its own logic and appearance. A component can be as small as a button, or as large as an entire page. + +React components are Python functions that return markup: + +```python linenums="0" +{% include "../../examples/quick_start/my_button.py" start="# start" %} +``` + +Now that you've declared `my_button`, you can nest it into another component: + +```python linenums="0" hl_lines="5" +{% include "../../examples/quick_start/my_app.py" start="# start" %} +``` + +Have a look at the result: + +=== "app.py" + + ```python + {% include "../../examples/quick_start/creating_and_nesting_components.py" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +<!-- ## Writing markup with JSX + +The markup syntax you've seen above is called _JSX_. It is optional, but most React projects use JSX for its convenience. All of the [tools we recommend for local development](/learn/installation) support JSX out of the box. + +JSX is stricter than HTML. You have to close tags like `<br />`. Your component also can't return multiple JSX tags. You have to wrap them into a shared parent, like a `<div>...</div>` or an empty `<>...</>` wrapper: + +```js +function AboutPage() { + return ( + <> + <h1>About</h1> + <p> + Hello there. + <br /> + How do you do? + </p> + </> + ); +} +``` + +If you have a lot of HTML to port to JSX, you can use an [online converter.](https://transform.tools/html-to-jsx) --> + +## Adding styles + +In React, you specify a CSS class with `className`. It works the same way as the HTML [`class`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class) attribute: + +```python linenums="0" +{% include "../../examples/quick_start/adding_styles.py" start="# start" %} +``` + +Then you write the CSS rules for it in a separate CSS file: + +```css linenums="0" +{% include "../../examples/quick_start/adding_styles.css" %} +``` + +React does not prescribe how you add CSS files. In the simplest case, you'll add a [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link) tag to your HTML. If you use a build tool or web framework, consult its documentation to learn how to add a CSS file to your project. + +## Displaying data + +<!-- JSX lets you put markup into JavaScript. Curly braces let you "escape back" into JavaScript so that you can embed some variable from your code and display it to the user. For example, this will display `user.name`: + +```js +return <h1>{user.name}</h1>; +``` + +You can also "escape into JavaScript" from JSX attributes, but you have to use curly braces _instead of_ quotes. For example, `className="avatar"` passes the `"avatar"` string as the CSS class, but `src={user.imageUrl}` reads the JavaScript `user.imageUrl` variable value, and then passes that value as the `src` attribute: + +```js +return <img className="avatar" src={user.imageUrl} />; +``` + +You can put more complex expressions inside the JSX curly braces too, for example, [string concatenation](https://javascript.info/operators#string-concatenation-with-binary): --> + +You can fetch data from a variety of sources and directly embed it into your components. You can also use the `style` attribute when your styles depend on JavaScript variables. + +=== "app.py" + + ```python + {% include "../../examples/quick_start/displaying_data.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/quick_start/displaying_data.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +## Conditional rendering + +In React, there is no special syntax for writing conditions. Instead, you'll use the same techniques as you use when writing regular Python code. For example, you can use an `if` statement to conditionally include components: + +```python linenums="0" +{% include "../../examples/quick_start/conditional_rendering.py" start="# start"%} +``` + +If you prefer more compact code, you can use the [ternary operator.](https://www.geeksforgeeks.org/ternary-operator-in-python/): + +```python linenums="0" +{% include "../../examples/quick_start/conditional_rendering_ternary.py" start="# start"%} +``` + +When you don't need the `else` branch, you can also use a shorter [logical `and` syntax](https://www.geeksforgeeks.org/short-circuiting-techniques-python/): + +```python linenums="0" +{% include "../../examples/quick_start/conditional_rendering_logical_and.py" start="# start" %} +``` + +All of these approaches also work for conditionally specifying attributes. If you're unfamiliar with some of this Python syntax, you can start by always using `if...else`. + +## Rendering lists + +You will rely on Python features like [`for` loop](https://www.w3schools.com/python/quick_start/python_for_loops.asp) and [list comprehension](https://www.w3schools.com/python/quick_start/python_lists_comprehension.asp) to render lists of components. + +For example, let's say you have an array of products: + +```python linenums="0" +{% include "../../examples/quick_start/rendering_lists_products.py" %} +``` + +Inside your component, use list comprehension to transform an array of products into an array of `#!html <li>` items: + +```python linenums="0" +{% include "../../examples/quick_start/rendering_lists_list_items.py" start="# start" %} +``` + +Notice how `#!html <li>` has a `key` attribute. For each item in a list, you should pass a string or a number that uniquely identifies that item among its siblings. Usually, a key should be coming from your data, such as a database ID. React uses your keys to know what happened if you later insert, delete, or reorder the items. + +=== "app.py" + + ```python + {% include "../../examples/quick_start/rendering_lists.py" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +## Responding to events + +You can respond to events by declaring _event handler_ functions inside your components: + +```python linenums="0" hl_lines="3-4 7" +{% include "../../examples/quick_start/responding_to_events.py" start="# start" %} +``` + +Notice how `"onClick": handle_click` has no parentheses at the end! Do not _call_ the event handler function: you only need to _pass it down_. React will call your event handler when the user clicks the button. + +## Updating the screen + +Often, you'll want your component to "remember" some information and display it. For example, maybe you want to count the number of times a button is clicked. To do this, add _state_ to your component. + +First, import [`use_state`](../reference/use-state.md) from React: + +```python linenums="0" +{% include "../../examples/quick_start/updating_the_screen_use_state.py" end="# end" %} +``` + +Now you can declare a _state variable_ inside your component: + +```python linenums="0" +{% include "../../examples/quick_start/updating_the_screen_use_state_button.py" start="# start" %} +``` + +You’ll get two things from `use_state`: the current state (`count`), and the function that lets you update it (`set_count`). You can give them any names, but the convention is to write `something, set_something = ...`. + +The first time the button is displayed, `count` will be `0` because you passed `0` to `use_state()`. When you want to change state, call `set_count()` and pass the new value to it. Clicking this button will increment the counter: + +```python linenums="0" hl_lines="6" +{% include "../../examples/quick_start/updating_the_screen_event.py" start="# start" %} +``` + +React will call your component function again. This time, `count` will be `1`. Then it will be `2`. And so on. + +If you render the same component multiple times, each will get its own state. Click each button separately: + +=== "app.py" + + ```python + {% include "../../examples/quick_start/updating_the_screen.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/quick_start/updating_the_screen.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +Notice how each button "remembers" its own `count` state and doesn't affect other buttons. + +## Using Hooks + +Functions starting with `use` are called _Hooks_. `use_state` is a built-in Hook provided by React. You can find other built-in Hooks in the [API reference.](../reference/use-state.md) You can also write your own Hooks by combining the existing ones. + +Hooks are more restrictive than other functions. You can only call Hooks _at the top_ of your components (or other Hooks). If you want to use `use_state` in a condition or a loop, extract a new component and put it there. + +## Sharing data between components + +In the previous example, each `my_button` had its own independent `count`, and when each button was clicked, only the `count` for the button clicked changed: + +<!-- TODO: Diagram --> + +However, often you'll need components to _share data and always update together_. + +To make both `my_button` components display the same `count` and update together, you need to move the state from the individual buttons "upwards" to the closest component containing all of them. + +In this example, it is `my_app`. + +<!-- TODO: Diagram --> + +Now when you click either button, the `count` in `my_app` will change, which will change both of the counts in `my_button`. Here's how you can express this in code. + +First, _move the state up_ from `my_button` into `my_app`: + +```python linenums="0" hl_lines="3-6 17" +{% include "../../examples/quick_start/sharing_data_between_components_move_state.py" start="# start" %} +``` + +Then, _pass the state down_ from `my_app` to each `my_button`, together with the shared click handler. You can pass information to `my_button` using props: + +```python linenums="0" hl_lines="10-11" +{% include "../../examples/quick_start/sharing_data_between_components_props.py" start="# start" end="# end" %} +``` + +The information you pass down like this is called _props_. Now the `my_app` component contains the `count` state and the `handle_click` event handler, and _passes both of them down as props_ to each of the buttons. + +Finally, change `my_button` to _read_ the props you have passed from its parent component: + +```python linenums="0" +{% include "../../examples/quick_start/sharing_data_between_components_button.py" start="# start" %} +``` + +When you click the button, the `on_click` handler fires. Each button's `on_click` prop was set to the `handle_click` function inside `my_app`, so the code inside of it runs. That code calls `set_count(count + 1)`, incrementing the `count` state variable. The new `count` value is passed as a prop to each button, so they all show the new value. This is called "lifting state up". By moving state up, you've shared it between components. + +=== "app.py" + + ```python + {% include "../../examples/quick_start/sharing_data_between_components.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/quick_start/sharing_data_between_components.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +## Next Steps + +By now, you know the basics of how to write React code! + +Check out the [Tutorial](./tutorial-tic-tac-toe.md) to put them into practice and build your first mini-app with React. diff --git a/docs/src/learn/react-developer-tools.md b/docs/src/learn/react-developer-tools.md new file mode 100644 index 000000000..9d1506b12 --- /dev/null +++ b/docs/src/learn/react-developer-tools.md @@ -0,0 +1,89 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. + + See [this issue](https://github.com/reactive-python/reactpy/issues/1072) for more details. + +<!-- +## Overview + +<p class="intro" markdown> + +Use React Developer Tools to inspect React [components](../learn/your-first-component.md), edit [props](../learn/passing-props-to-a-component.md) and [state](../learn/state-a-components-memory.md), and identify performance problems. + +</p> + +!!! summary "You will learn" + + - How to install ReactPy Developer Tools + + +## Browser extension + +The easiest way to debug websites built with React is to install the React Developer Tools browser extension. It is available for several popular browsers: + +- [Install for **Chrome**](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) +- [Install for **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/react-devtools/) +- [Install for **Edge**](https://microsoftedge.microsoft.com/addons/detail/react-developer-tools/gpphkfbcpidddadnkolkpfckpihlkkil) + +Now, if you visit a website **built with React,** you will see the _Components_ and _Profiler_ panels. + + + +### Safari and other browsers + +For other browsers (for example, Safari), install the [`react-devtools`](https://www.npmjs.com/package/react-devtools) npm package: + +```bash +# Yarn +yarn global add react-devtools + +# Npm +npm install -g react-devtools +``` + +Next open the developer tools from the terminal: + +```bash +react-devtools +``` + +Then connect your website by adding the following `<script>` tag to the beginning of your website's `<head>`: + +```html +<html> + <head> + <script src="http://localhost:8097"></script> + </head> +</html> +``` + +Reload your website in the browser now to view it in developer tools. + + + +## Mobile (React Native) + +React Developer Tools can be used to inspect apps built with [React Native](https://reactnative.dev/) as well. + +The easiest way to use React Developer Tools is to install it globally: + +```bash +# Yarn +yarn global add react-devtools + +# Npm +npm install -g react-devtools +``` + +Next open the developer tools from the terminal. + +```bash +react-devtools +``` + +It should connect to any local React Native app that's running. + +> Try reloading the app if developer tools doesn't connect after a few seconds. + +[Learn more about debugging React Native.](https://reactnative.dev/docs/debugging) --> diff --git a/docs/src/learn/reacting-to-input-with-state.md b/docs/src/learn/reacting-to-input-with-state.md new file mode 100644 index 000000000..4247a88d1 --- /dev/null +++ b/docs/src/learn/reacting-to-input-with-state.md @@ -0,0 +1,1175 @@ +## Overview + +<p class="intro" markdown> + +React provides a declarative way to manipulate the UI. Instead of manipulating individual pieces of the UI directly, you describe the different states that your component can be in, and switch between them in response to the user input. This is similar to how designers think about the UI. + +</p> + +!!! summary "You will learn" + + - How declarative UI programming differs from imperative UI programming + - How to enumerate the different visual states your component can be in + - How to trigger the changes between the different visual states from code + +## How declarative UI compares to imperative + +When you design UI interactions, you probably think about how the UI _changes_ in response to user actions. Consider a form that lets the user submit an answer: + +- When you type something into the form, the "Submit" button **becomes enabled.** +- When you press "Submit", both the form and the button **become disabled,** and a spinner **appears.** +- If the network request succeeds, the form **gets hidden,** and the "Thank you" message **appears.** +- If the network request fails, an error message **appears,** and the form **becomes enabled** again. + +In **imperative programming,** the above corresponds directly to how you implement interaction. You have to write the exact instructions to manipulate the UI depending on what just happened. Here's another way to think about this: imagine riding next to someone in a car and telling them turn by turn where to go. + +<Illustration src="/images/docs/illustrations/i_imperative-ui-programming.png" alt="In a car driven by an anxious-looking person representing JavaScript, a passenger orders the driver to execute a sequence of complicated turn by turn navigations." /> + +They don't know where you want to go, they just follow your commands. (And if you get the directions wrong, you end up in the wrong place!) It's called _imperative_ because you have to "command" each element, from the spinner to the button, telling the computer _how_ to update the UI. + +In this example of imperative UI programming, the form is built _without_ React. It only uses the browser [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model): + +```js +async function handleFormSubmit(e) { + e.preventDefault(); + disable(textarea); + disable(button); + show(loadingMessage); + hide(errorMessage); + try { + await submitForm(textarea.value); + show(successMessage); + hide(form); + } catch (err) { + show(errorMessage); + errorMessage.textContent = err.message; + } finally { + hide(loadingMessage); + enable(textarea); + enable(button); + } +} + +function handleTextareaChange() { + if (textarea.value.length === 0) { + disable(button); + } else { + enable(button); + } +} + +function hide(el) { + el.style.display = "none"; +} + +function show(el) { + el.style.display = ""; +} + +function enable(el) { + el.disabled = false; +} + +function disable(el) { + el.disabled = true; +} + +function submitForm(answer) { + // Pretend it's hitting the network. + return new Promise((resolve, reject) => { + setTimeout(() => { + if (answer.toLowerCase() == "istanbul") { + resolve(); + } else { + reject(new Error("Good guess but a wrong answer. Try again!")); + } + }, 1500); + }); +} + +let form = document.getElementById("form"); +let textarea = document.getElementById("textarea"); +let button = document.getElementById("button"); +let loadingMessage = document.getElementById("loading"); +let errorMessage = document.getElementById("error"); +let successMessage = document.getElementById("success"); +form.onsubmit = handleFormSubmit; +textarea.oninput = handleTextareaChange; +``` + +```js +{ + "hardReloadOnChange": true +} +``` + +```html +<form id="form"> + <h2>City quiz</h2> + <p>What city is located on two continents?</p> + <textarea id="textarea"></textarea> + <br /> + <button id="button" disabled>Submit</button> + <p id="loading" style="display: none">Loading...</p> + <p id="error" style="display: none; color: red;"></p> +</form> +<h1 id="success" style="display: none">That's right!</h1> + +<style> + * { + box-sizing: border-box; + } + body { + font-family: sans-serif; + margin: 20px; + padding: 0; + } +</style> +``` + +Manipulating the UI imperatively works well enough for isolated examples, but it gets exponentially more difficult to manage in more complex systems. Imagine updating a page full of different forms like this one. Adding a new UI element or a new interaction would require carefully checking all existing code to make sure you haven't introduced a bug (for example, forgetting to show or hide something). + +React was built to solve this problem. + +In React, you don't directly manipulate the UI--meaning you don't enable, disable, show, or hide components directly. Instead, you **declare what you want to show,** and React figures out how to update the UI. Think of getting into a taxi and telling the driver where you want to go instead of telling them exactly where to turn. It's the driver's job to get you there, and they might even know some shortcuts you haven't considered! + +<Illustration src="/images/docs/illustrations/i_declarative-ui-programming.png" alt="In a car driven by React, a passenger asks to be taken to a specific place on the map. React figures out how to do that." /> + +## Thinking about UI declaratively + +You've seen how to implement a form imperatively above. To better understand how to think in React, you'll walk through reimplementing this UI in React below: + +1. **Identify** your component's different visual states +2. **Determine** what triggers those state changes +3. **Represent** the state in memory using `useState` +4. **Remove** any non-essential state variables +5. **Connect** the event handlers to set the state + +### Step 1: Identify your component's different visual states + +In computer science, you may hear about a ["state machine"](https://en.wikipedia.org/wiki/Finite-state_machine) being in one of several “states”. If you work with a designer, you may have seen mockups for different "visual states". React stands at the intersection of design and computer science, so both of these ideas are sources of inspiration. + +First, you need to visualize all the different "states" of the UI the user might see: + +- **Empty**: Form has a disabled "Submit" button. +- **Typing**: Form has an enabled "Submit" button. +- **Submitting**: Form is completely disabled. Spinner is shown. +- **Success**: "Thank you" message is shown instead of a form. +- **Error**: Same as Typing state, but with an extra error message. + +Just like a designer, you'll want to "mock up" or create "mocks" for the different states before you add logic. For example, here is a mock for just the visual part of the form. This mock is controlled by a prop called `status` with a default value of `'empty'`: + +```js +export default function Form({ status = "empty" }) { + if (status === "success") { + return <h1>That's right!</h1>; + } + return ( + <> + <h2>City quiz</h2> + <p> + In which city is there a billboard that turns air into drinkable + water? + </p> + <form> + <textarea /> + <br /> + <button>Submit</button> + </form> + </> + ); +} +``` + +You could call that prop anything you like, the naming is not important. Try editing `status = 'empty'` to `status = 'success'` to see the success message appear. Mocking lets you quickly iterate on the UI before you wire up any logic. Here is a more fleshed out prototype of the same component, still "controlled" by the `status` prop: + +```js +export default function Form({ + // Try 'submitting', 'error', 'success': + status = "empty", +}) { + if (status === "success") { + return <h1>That's right!</h1>; + } + return ( + <> + <h2>City quiz</h2> + <p> + In which city is there a billboard that turns air into drinkable + water? + </p> + <form> + <textarea disabled={status === "submitting"} /> + <br /> + <button + disabled={status === "empty" || status === "submitting"} + > + Submit + </button> + {status === "error" && ( + <p className="Error"> + Good guess but a wrong answer. Try again! + </p> + )} + </form> + </> + ); +} +``` + +```css +.Error { + color: red; +} +``` + +<DeepDive> + +#### Displaying many visual states at once + +If a component has a lot of visual states, it can be convenient to show them all on one page: + +```js +import Form from "./Form.js"; + +let statuses = ["empty", "typing", "submitting", "success", "error"]; + +export default function App() { + return ( + <> + {statuses.map((status) => ( + <section key={status}> + <h4>Form ({status}):</h4> + <Form status={status} /> + </section> + ))} + </> + ); +} +``` + +```js +export default function Form({ status }) { + if (status === "success") { + return <h1>That's right!</h1>; + } + return ( + <form> + <textarea disabled={status === "submitting"} /> + <br /> + <button disabled={status === "empty" || status === "submitting"}> + Submit + </button> + {status === "error" && ( + <p className="Error"> + Good guess but a wrong answer. Try again! + </p> + )} + </form> + ); +} +``` + +```css +section { + border-bottom: 1px solid #aaa; + padding: 20px; +} +h4 { + color: #222; +} +body { + margin: 0; +} +.Error { + color: red; +} +``` + +Pages like this are often called "living styleguides" or "storybooks". + +</DeepDive> + +### Step 2: Determine what triggers those state changes + +You can trigger state updates in response to two kinds of inputs: + +- **Human inputs,** like clicking a button, typing in a field, navigating a link. +- **Computer inputs,** like a network response arriving, a timeout completing, an image loading. + +<IllustrationBlock> + <Illustration caption="Human inputs" alt="A finger." src="/images/docs/illustrations/i_inputs1.png" /> + <Illustration caption="Computer inputs" alt="Ones and zeroes." src="/images/docs/illustrations/i_inputs2.png" /> +</IllustrationBlock> + +In both cases, **you must set [state variables](/learn/state-a-components-memory#anatomy-of-usestate) to update the UI.** For the form you're developing, you will need to change state in response to a few different inputs: + +- **Changing the text input** (human) should switch it from the _Empty_ state to the _Typing_ state or back, depending on whether the text box is empty or not. +- **Clicking the Submit button** (human) should switch it to the _Submitting_ state. +- **Successful network response** (computer) should switch it to the _Success_ state. +- **Failed network response** (computer) should switch it to the _Error_ state with the matching error message. + +<Note> + +Notice that human inputs often require [event handlers](/learn/responding-to-events)! + +</Note> + +To help visualize this flow, try drawing each state on paper as a labeled circle, and each change between two states as an arrow. You can sketch out many flows this way and sort out bugs long before implementation. + +<!-- TODO: Diagram --> + +### Step 3: Represent the state in memory with `useState` + +Next you'll need to represent the visual states of your component in memory with [`useState`.](/reference/react/useState) Simplicity is key: each piece of state is a "moving piece", and **you want as few "moving pieces" as possible.** More complexity leads to more bugs! + +Start with the state that _absolutely must_ be there. For example, you'll need to store the `answer` for the input, and the `error` (if it exists) to store the last error: + +```js +const [answer, setAnswer] = useState(""); +const [error, setError] = useState(null); +``` + +Then, you'll need a state variable representing which one of the visual states that you want to display. There's usually more than a single way to represent that in memory, so you'll need to experiment with it. + +If you struggle to think of the best way immediately, start by adding enough state that you're _definitely_ sure that all the possible visual states are covered: + +```js +const [isEmpty, setIsEmpty] = useState(true); +const [isTyping, setIsTyping] = useState(false); +const [isSubmitting, setIsSubmitting] = useState(false); +const [isSuccess, setIsSuccess] = useState(false); +const [isError, setIsError] = useState(false); +``` + +Your first idea likely won't be the best, but that's ok--refactoring state is a part of the process! + +### Step 4: Remove any non-essential state variables + +You want to avoid duplication in the state content so you're only tracking what is essential. Spending a little time on refactoring your state structure will make your components easier to understand, reduce duplication, and avoid unintended meanings. Your goal is to **prevent the cases where the state in memory doesn't represent any valid UI that you'd want a user to see.** (For example, you never want to show an error message and disable the input at the same time, or the user won't be able to correct the error!) + +Here are some questions you can ask about your state variables: + +- **Does this state cause a paradox?** For example, `isTyping` and `isSubmitting` can't both be `true`. A paradox usually means that the state is not constrained enough. There are four possible combinations of two booleans, but only three correspond to valid states. To remove the "impossible" state, you can combine these into a `status` that must be one of three values: `'typing'`, `'submitting'`, or `'success'`. +- **Is the same information available in another state variable already?** Another paradox: `isEmpty` and `isTyping` can't be `true` at the same time. By making them separate state variables, you risk them going out of sync and causing bugs. Fortunately, you can remove `isEmpty` and instead check `answer.length === 0`. +- **Can you get the same information from the inverse of another state variable?** `isError` is not needed because you can check `error !== null` instead. + +After this clean-up, you're left with 3 (down from 7!) _essential_ state variables: + +```js +const [answer, setAnswer] = useState(""); +const [error, setError] = useState(null); +const [status, setStatus] = useState("typing"); // 'typing', 'submitting', or 'success' +``` + +You know they are essential, because you can't remove any of them without breaking the functionality. + +<DeepDive> + +#### Eliminating “impossible” states with a reducer + +These three variables are a good enough representation of this form's state. However, there are still some intermediate states that don't fully make sense. For example, a non-null `error` doesn't make sense when `status` is `'success'`. To model the state more precisely, you can [extract it into a reducer.](/learn/extracting-state-logic-into-a-reducer) Reducers let you unify multiple state variables into a single object and consolidate all the related logic! + +</DeepDive> + +### Step 5: Connect the event handlers to set state + +Lastly, create event handlers that update the state. Below is the final form, with all event handlers wired up: + +```js +import { useState } from "react"; + +export default function Form() { + const [answer, setAnswer] = useState(""); + const [error, setError] = useState(null); + const [status, setStatus] = useState("typing"); + + if (status === "success") { + return <h1>That's right!</h1>; + } + + async function handleSubmit(e) { + e.preventDefault(); + setStatus("submitting"); + try { + await submitForm(answer); + setStatus("success"); + } catch (err) { + setStatus("typing"); + setError(err); + } + } + + function handleTextareaChange(e) { + setAnswer(e.target.value); + } + + return ( + <> + <h2>City quiz</h2> + <p> + In which city is there a billboard that turns air into drinkable + water? + </p> + <form onSubmit={handleSubmit}> + <textarea + value={answer} + onChange={handleTextareaChange} + disabled={status === "submitting"} + /> + <br /> + <button + disabled={answer.length === 0 || status === "submitting"} + > + Submit + </button> + {error !== null && <p className="Error">{error.message}</p>} + </form> + </> + ); +} + +function submitForm(answer) { + // Pretend it's hitting the network. + return new Promise((resolve, reject) => { + setTimeout(() => { + let shouldError = answer.toLowerCase() !== "lima"; + if (shouldError) { + reject(new Error("Good guess but a wrong answer. Try again!")); + } else { + resolve(); + } + }, 1500); + }); +} +``` + +```css +.Error { + color: red; +} +``` + +Although this code is longer than the original imperative example, it is much less fragile. Expressing all interactions as state changes lets you later introduce new visual states without breaking existing ones. It also lets you change what should be displayed in each state without changing the logic of the interaction itself. + +<Recap> + +- Declarative programming means describing the UI for each visual state rather than micromanaging the UI (imperative). +- When developing a component: + 1. Identify all its visual states. + 2. Determine the human and computer triggers for state changes. + 3. Model the state with `useState`. + 4. Remove non-essential state to avoid bugs and paradoxes. + 5. Connect the event handlers to set state. + +</Recap> + +<Challenges> + +#### Add and remove a CSS class + +Make it so that clicking on the picture _removes_ the `background--active` CSS class from the outer `<div>`, but _adds_ the `picture--active` class to the `<img>`. Clicking the background again should restore the original CSS classes. + +Visually, you should expect that clicking on the picture removes the purple background and highlights the picture border. Clicking outside the picture highlights the background, but removes the picture border highlight. + +```js +export default function Picture() { + return ( + <div className="background background--active"> + <img + className="picture" + alt="Rainbow houses in Kampung Pelangi, Indonesia" + src="https://i.imgur.com/5qwVYb1.jpeg" + /> + </div> + ); +} +``` + +```css +body { + margin: 0; + padding: 0; + height: 250px; +} + +.background { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: #eee; +} + +.background--active { + background: #a6b5ff; +} + +.picture { + width: 200px; + height: 200px; + border-radius: 10px; +} + +.picture--active { + border: 5px solid #a6b5ff; +} +``` + +<Solution> + +This component has two visual states: when the image is active, and when the image is inactive: + +- When the image is active, the CSS classes are `background` and `picture picture--active`. +- When the image is inactive, the CSS classes are `background background--active` and `picture`. + +A single boolean state variable is enough to remember whether the image is active. The original task was to remove or add CSS classes. However, in React you need to _describe_ what you want to see rather than _manipulate_ the UI elements. So you need to calculate both CSS classes based on the current state. You also need to [stop the propagation](/learn/responding-to-events#stopping-propagation) so that clicking the image doesn't register as a click on the background. + +Verify that this version works by clicking the image and then outside of it: + +```js +import { useState } from "react"; + +export default function Picture() { + const [isActive, setIsActive] = useState(false); + + let backgroundClassName = "background"; + let pictureClassName = "picture"; + if (isActive) { + pictureClassName += " picture--active"; + } else { + backgroundClassName += " background--active"; + } + + return ( + <div + className={backgroundClassName} + on_click={() => setIsActive(false)} + > + <img + on_click={(e) => { + e.stopPropagation(); + setIsActive(true); + }} + className={pictureClassName} + alt="Rainbow houses in Kampung Pelangi, Indonesia" + src="https://i.imgur.com/5qwVYb1.jpeg" + /> + </div> + ); +} +``` + +```css +body { + margin: 0; + padding: 0; + height: 250px; +} + +.background { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: #eee; +} + +.background--active { + background: #a6b5ff; +} + +.picture { + width: 200px; + height: 200px; + border-radius: 10px; + border: 5px solid transparent; +} + +.picture--active { + border: 5px solid #a6b5ff; +} +``` + +Alternatively, you could return two separate chunks of JSX: + +```js +import { useState } from "react"; + +export default function Picture() { + const [isActive, setIsActive] = useState(false); + if (isActive) { + return ( + <div className="background" on_click={() => setIsActive(false)}> + <img + className="picture picture--active" + alt="Rainbow houses in Kampung Pelangi, Indonesia" + src="https://i.imgur.com/5qwVYb1.jpeg" + on_click={(e) => e.stopPropagation()} + /> + </div> + ); + } + return ( + <div className="background background--active"> + <img + className="picture" + alt="Rainbow houses in Kampung Pelangi, Indonesia" + src="https://i.imgur.com/5qwVYb1.jpeg" + on_click={() => setIsActive(true)} + /> + </div> + ); +} +``` + +```css +body { + margin: 0; + padding: 0; + height: 250px; +} + +.background { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: #eee; +} + +.background--active { + background: #a6b5ff; +} + +.picture { + width: 200px; + height: 200px; + border-radius: 10px; + border: 5px solid transparent; +} + +.picture--active { + border: 5px solid #a6b5ff; +} +``` + +Keep in mind that if two different JSX chunks describe the same tree, their nesting (first `<div>` → first `<img>`) has to line up. Otherwise, toggling `isActive` would recreate the whole tree below and [reset its state.](/learn/preserving-and-resetting-state) This is why, if a similar JSX tree gets returned in both cases, it is better to write them as a single piece of JSX. + +</Solution> + +#### Profile editor + +Here is a small form implemented with plain JavaScript and DOM. Play with it to understand its behavior: + +```js +function handleFormSubmit(e) { + e.preventDefault(); + if (editButton.textContent === "Edit Profile") { + editButton.textContent = "Save Profile"; + hide(firstNameText); + hide(lastNameText); + show(firstNameInput); + show(lastNameInput); + } else { + editButton.textContent = "Edit Profile"; + hide(firstNameInput); + hide(lastNameInput); + show(firstNameText); + show(lastNameText); + } +} + +function handleFirstNameChange() { + firstNameText.textContent = firstNameInput.value; + helloText.textContent = + "Hello " + firstNameInput.value + " " + lastNameInput.value + "!"; +} + +function handleLastNameChange() { + lastNameText.textContent = lastNameInput.value; + helloText.textContent = + "Hello " + firstNameInput.value + " " + lastNameInput.value + "!"; +} + +function hide(el) { + el.style.display = "none"; +} + +function show(el) { + el.style.display = ""; +} + +let form = document.getElementById("form"); +let editButton = document.getElementById("editButton"); +let firstNameInput = document.getElementById("firstNameInput"); +let firstNameText = document.getElementById("firstNameText"); +let lastNameInput = document.getElementById("lastNameInput"); +let lastNameText = document.getElementById("lastNameText"); +let helloText = document.getElementById("helloText"); +form.onsubmit = handleFormSubmit; +firstNameInput.oninput = handleFirstNameChange; +lastNameInput.oninput = handleLastNameChange; +``` + +```js +{ + "hardReloadOnChange": true +} +``` + +```html +<form id="form"> + <label> + First name: + <b id="firstNameText">Jane</b> + <input id="firstNameInput" value="Jane" style="display: none" /> + </label> + <label> + Last name: + <b id="lastNameText">Jacobs</b> + <input id="lastNameInput" value="Jacobs" style="display: none" /> + </label> + <button type="submit" id="editButton">Edit Profile</button> + <p><i id="helloText">Hello, Jane Jacobs!</i></p> +</form> + +<style> + * { + box-sizing: border-box; + } + body { + font-family: sans-serif; + margin: 20px; + padding: 0; + } + label { + display: block; + margin-bottom: 20px; + } +</style> +``` + +This form switches between two modes: in the editing mode, you see the inputs, and in the viewing mode, you only see the result. The button label changes between "Edit" and "Save" depending on the mode you're in. When you change the inputs, the welcome message at the bottom updates in real time. + +Your task is to reimplement it in React in the sandbox below. For your convenience, the markup was already converted to JSX, but you'll need to make it show and hide the inputs like the original does. + +Make sure that it updates the text at the bottom, too! + +```js +export default function EditProfile() { + return ( + <form> + <label> + First name: <b>Jane</b> + <input /> + </label> + <label> + Last name: <b>Jacobs</b> + <input /> + </label> + <button type="submit">Edit Profile</button> + <p> + <i>Hello, Jane Jacobs!</i> + </p> + </form> + ); +} +``` + +```css +label { + display: block; + margin-bottom: 20px; +} +``` + +<Solution> + +You will need two state variables to hold the input values: `firstName` and `lastName`. You're also going to need an `isEditing` state variable that holds whether to display the inputs or not. You should _not_ need a `fullName` variable because the full name can always be calculated from the `firstName` and the `lastName`. + +Finally, you should use [conditional rendering](/learn/conditional-rendering) to show or hide the inputs depending on `isEditing`. + +```js +import { useState } from "react"; + +export default function EditProfile() { + const [isEditing, setIsEditing] = useState(false); + const [firstName, setFirstName] = useState("Jane"); + const [lastName, setLastName] = useState("Jacobs"); + + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + setIsEditing(!isEditing); + }} + > + <label> + First name:{" "} + {isEditing ? ( + <input + value={firstName} + onChange={(e) => { + setFirstName(e.target.value); + }} + /> + ) : ( + <b>{firstName}</b> + )} + </label> + <label> + Last name:{" "} + {isEditing ? ( + <input + value={lastName} + onChange={(e) => { + setLastName(e.target.value); + }} + /> + ) : ( + <b>{lastName}</b> + )} + </label> + <button type="submit">{isEditing ? "Save" : "Edit"} Profile</button> + <p> + <i> + Hello, {firstName} {lastName}! + </i> + </p> + </form> + ); +} +``` + +```css +label { + display: block; + margin-bottom: 20px; +} +``` + +Compare this solution to the original imperative code. How are they different? + +</Solution> + +#### Refactor the imperative solution without React + +Here is the original sandbox from the previous challenge, written imperatively without React: + +```js +function handleFormSubmit(e) { + e.preventDefault(); + if (editButton.textContent === "Edit Profile") { + editButton.textContent = "Save Profile"; + hide(firstNameText); + hide(lastNameText); + show(firstNameInput); + show(lastNameInput); + } else { + editButton.textContent = "Edit Profile"; + hide(firstNameInput); + hide(lastNameInput); + show(firstNameText); + show(lastNameText); + } +} + +function handleFirstNameChange() { + firstNameText.textContent = firstNameInput.value; + helloText.textContent = + "Hello " + firstNameInput.value + " " + lastNameInput.value + "!"; +} + +function handleLastNameChange() { + lastNameText.textContent = lastNameInput.value; + helloText.textContent = + "Hello " + firstNameInput.value + " " + lastNameInput.value + "!"; +} + +function hide(el) { + el.style.display = "none"; +} + +function show(el) { + el.style.display = ""; +} + +let form = document.getElementById("form"); +let editButton = document.getElementById("editButton"); +let firstNameInput = document.getElementById("firstNameInput"); +let firstNameText = document.getElementById("firstNameText"); +let lastNameInput = document.getElementById("lastNameInput"); +let lastNameText = document.getElementById("lastNameText"); +let helloText = document.getElementById("helloText"); +form.onsubmit = handleFormSubmit; +firstNameInput.oninput = handleFirstNameChange; +lastNameInput.oninput = handleLastNameChange; +``` + +```js +{ + "hardReloadOnChange": true +} +``` + +```html +<form id="form"> + <label> + First name: + <b id="firstNameText">Jane</b> + <input id="firstNameInput" value="Jane" style="display: none" /> + </label> + <label> + Last name: + <b id="lastNameText">Jacobs</b> + <input id="lastNameInput" value="Jacobs" style="display: none" /> + </label> + <button type="submit" id="editButton">Edit Profile</button> + <p><i id="helloText">Hello, Jane Jacobs!</i></p> +</form> + +<style> + * { + box-sizing: border-box; + } + body { + font-family: sans-serif; + margin: 20px; + padding: 0; + } + label { + display: block; + margin-bottom: 20px; + } +</style> +``` + +Imagine React didn't exist. Can you refactor this code in a way that makes the logic less fragile and more similar to the React version? What would it look like if the state was explicit, like in React? + +If you're struggling to think where to start, the stub below already has most of the structure in place. If you start here, fill in the missing logic in the `updateDOM` function. (Refer to the original code where needed.) + +```js +let firstName = "Jane"; +let lastName = "Jacobs"; +let isEditing = false; + +function handleFormSubmit(e) { + e.preventDefault(); + setIsEditing(!isEditing); +} + +function handleFirstNameChange(e) { + setFirstName(e.target.value); +} + +function handleLastNameChange(e) { + setLastName(e.target.value); +} + +function setFirstName(value) { + firstName = value; + updateDOM(); +} + +function setLastName(value) { + lastName = value; + updateDOM(); +} + +function setIsEditing(value) { + isEditing = value; + updateDOM(); +} + +function updateDOM() { + if (isEditing) { + editButton.textContent = "Save Profile"; + // TODO: show inputs, hide content + } else { + editButton.textContent = "Edit Profile"; + // TODO: hide inputs, show content + } + // TODO: update text labels +} + +function hide(el) { + el.style.display = "none"; +} + +function show(el) { + el.style.display = ""; +} + +let form = document.getElementById("form"); +let editButton = document.getElementById("editButton"); +let firstNameInput = document.getElementById("firstNameInput"); +let firstNameText = document.getElementById("firstNameText"); +let lastNameInput = document.getElementById("lastNameInput"); +let lastNameText = document.getElementById("lastNameText"); +let helloText = document.getElementById("helloText"); +form.onsubmit = handleFormSubmit; +firstNameInput.oninput = handleFirstNameChange; +lastNameInput.oninput = handleLastNameChange; +``` + +```js +{ + "hardReloadOnChange": true +} +``` + +```html +<form id="form"> + <label> + First name: + <b id="firstNameText">Jane</b> + <input id="firstNameInput" value="Jane" style="display: none" /> + </label> + <label> + Last name: + <b id="lastNameText">Jacobs</b> + <input id="lastNameInput" value="Jacobs" style="display: none" /> + </label> + <button type="submit" id="editButton">Edit Profile</button> + <p><i id="helloText">Hello, Jane Jacobs!</i></p> +</form> + +<style> + * { + box-sizing: border-box; + } + body { + font-family: sans-serif; + margin: 20px; + padding: 0; + } + label { + display: block; + margin-bottom: 20px; + } +</style> +``` + +<Solution> + +The missing logic included toggling the display of inputs and content, and updating the labels: + +```js +let firstName = "Jane"; +let lastName = "Jacobs"; +let isEditing = false; + +function handleFormSubmit(e) { + e.preventDefault(); + setIsEditing(!isEditing); +} + +function handleFirstNameChange(e) { + setFirstName(e.target.value); +} + +function handleLastNameChange(e) { + setLastName(e.target.value); +} + +function setFirstName(value) { + firstName = value; + updateDOM(); +} + +function setLastName(value) { + lastName = value; + updateDOM(); +} + +function setIsEditing(value) { + isEditing = value; + updateDOM(); +} + +function updateDOM() { + if (isEditing) { + editButton.textContent = "Save Profile"; + hide(firstNameText); + hide(lastNameText); + show(firstNameInput); + show(lastNameInput); + } else { + editButton.textContent = "Edit Profile"; + hide(firstNameInput); + hide(lastNameInput); + show(firstNameText); + show(lastNameText); + } + firstNameText.textContent = firstName; + lastNameText.textContent = lastName; + helloText.textContent = "Hello " + firstName + " " + lastName + "!"; +} + +function hide(el) { + el.style.display = "none"; +} + +function show(el) { + el.style.display = ""; +} + +let form = document.getElementById("form"); +let editButton = document.getElementById("editButton"); +let firstNameInput = document.getElementById("firstNameInput"); +let firstNameText = document.getElementById("firstNameText"); +let lastNameInput = document.getElementById("lastNameInput"); +let lastNameText = document.getElementById("lastNameText"); +let helloText = document.getElementById("helloText"); +form.onsubmit = handleFormSubmit; +firstNameInput.oninput = handleFirstNameChange; +lastNameInput.oninput = handleLastNameChange; +``` + +```js +{ + "hardReloadOnChange": true +} +``` + +```html +<form id="form"> + <label> + First name: + <b id="firstNameText">Jane</b> + <input id="firstNameInput" value="Jane" style="display: none" /> + </label> + <label> + Last name: + <b id="lastNameText">Jacobs</b> + <input id="lastNameInput" value="Jacobs" style="display: none" /> + </label> + <button type="submit" id="editButton">Edit Profile</button> + <p><i id="helloText">Hello, Jane Jacobs!</i></p> +</form> + +<style> + * { + box-sizing: border-box; + } + body { + font-family: sans-serif; + margin: 20px; + padding: 0; + } + label { + display: block; + margin-bottom: 20px; + } +</style> +``` + +The `updateDOM` function you wrote shows what React does under the hood when you set the state. (However, React also avoids touching the DOM for properties that have not changed since the last time they were set.) + +</Solution> + +</Challenges> diff --git a/docs/src/learn/referencing-values-with-refs.md b/docs/src/learn/referencing-values-with-refs.md new file mode 100644 index 000000000..d51acd757 --- /dev/null +++ b/docs/src/learn/referencing-values-with-refs.md @@ -0,0 +1,565 @@ +## Overview + +<p class="intro" markdown> + +When you want a component to "remember" some information, but you don't want that information to [trigger new renders](/learn/render-and-commit), you can use a _ref_. + +</p> + +!!! summary "You will learn" + + - How to add a ref to your component + - How to update a ref's value + - How refs are different from state + - How to use refs safely + +## Adding a ref to your component + +You can add a ref to your component by importing the `useRef` Hook from React: + +```js +import { useRef } from "react"; +``` + +Inside your component, call the `useRef` Hook and pass the initial value that you want to reference as the only argument. For example, here is a ref to the value `0`: + +```js +const ref = useRef(0); +``` + +`useRef` returns an object like this: + +```js +{ + current: 0; // The value you passed to useRef +} +``` + +<Illustration src="/images/docs/illustrations/i_ref.png" alt="An arrow with 'current' written on it stuffed into a pocket with 'ref' written on it." /> + +You can access the current value of that ref through the `ref.current` property. This value is intentionally mutable, meaning you can both read and write to it. It's like a secret pocket of your component that React doesn't track. (This is what makes it an "escape hatch" from React's one-way data flow--more on that below!) + +Here, a button will increment `ref.current` on every click: + +```js +import { useRef } from "react"; + +export default function Counter() { + let ref = useRef(0); + + function handleClick() { + ref.current = ref.current + 1; + alert("You clicked " + ref.current + " times!"); + } + + return <button on_click={handleClick}>Click me!</button>; +} +``` + +The ref points to a number, but, like [state](/learn/state-a-components-memory), you could point to anything: a string, an object, or even a function. Unlike state, ref is a plain JavaScript object with the `current` property that you can read and modify. + +Note that **the component doesn't re-render with every increment.** Like state, refs are retained by React between re-renders. However, setting state re-renders a component. Changing a ref does not! + +## Example: building a stopwatch + +You can combine refs and state in a single component. For example, let's make a stopwatch that the user can start or stop by pressing a button. In order to display how much time has passed since the user pressed "Start", you will need to keep track of when the Start button was pressed and what the current time is. **This information is used for rendering, so you'll keep it in state:** + +```js +const [startTime, setStartTime] = useState(null); +const [now, setNow] = useState(null); +``` + +When the user presses "Start", you'll use [`setInterval`](https://developer.mozilla.org/docs/Web/API/setInterval) in order to update the time every 10 milliseconds: + +```js +import { useState } from "react"; + +export default function Stopwatch() { + const [startTime, setStartTime] = useState(null); + const [now, setNow] = useState(null); + + function handleStart() { + // Start counting. + setStartTime(Date.now()); + setNow(Date.now()); + + setInterval(() => { + // Update the current time every 10ms. + setNow(Date.now()); + }, 10); + } + + let secondsPassed = 0; + if (startTime != null && now != null) { + secondsPassed = (now - startTime) / 1000; + } + + return ( + <> + <h1>Time passed: {secondsPassed.toFixed(3)}</h1> + <button on_click={handleStart}>Start</button> + </> + ); +} +``` + +When the "Stop" button is pressed, you need to cancel the existing interval so that it stops updating the `now` state variable. You can do this by calling [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval), but you need to give it the interval ID that was previously returned by the `setInterval` call when the user pressed Start. You need to keep the interval ID somewhere. **Since the interval ID is not used for rendering, you can keep it in a ref:** + +```js +import { useState, useRef } from "react"; + +export default function Stopwatch() { + const [startTime, setStartTime] = useState(null); + const [now, setNow] = useState(null); + const intervalRef = useRef(null); + + function handleStart() { + setStartTime(Date.now()); + setNow(Date.now()); + + clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + setNow(Date.now()); + }, 10); + } + + function handleStop() { + clearInterval(intervalRef.current); + } + + let secondsPassed = 0; + if (startTime != null && now != null) { + secondsPassed = (now - startTime) / 1000; + } + + return ( + <> + <h1>Time passed: {secondsPassed.toFixed(3)}</h1> + <button on_click={handleStart}>Start</button> + <button on_click={handleStop}>Stop</button> + </> + ); +} +``` + +When a piece of information is used for rendering, keep it in state. When a piece of information is only needed by event handlers and changing it doesn't require a re-render, using a ref may be more efficient. + +## Differences between refs and state + +Perhaps you're thinking refs seem less "strict" than state—you can mutate them instead of always having to use a state setting function, for instance. But in most cases, you'll want to use state. Refs are an "escape hatch" you won't need often. Here's how state and refs compare: + +| refs | state | +| --- | --- | +| `useRef(initialValue)` returns `{ current: initialValue }` | `useState(initialValue)` returns the current value of a state variable and a state setter function ( `[value, setValue]`) | +| Doesn't trigger re-render when you change it. | Triggers re-render when you change it. | +| Mutable—you can modify and update `current`'s value outside of the rendering process. | "Immutable"—you must use the state setting function to modify state variables to queue a re-render. | +| You shouldn't read (or write) the `current` value during rendering. | You can read state at any time. However, each render has its own [snapshot](/learn/state-as-a-snapshot) of state which does not change. | + +Here is a counter button that's implemented with state: + +```js +import { useState } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + + function handleClick() { + setCount(count + 1); + } + + return <button on_click={handleClick}>You clicked {count} times</button>; +} +``` + +Because the `count` value is displayed, it makes sense to use a state value for it. When the counter's value is set with `setCount()`, React re-renders the component and the screen updates to reflect the new count. + +If you tried to implement this with a ref, React would never re-render the component, so you'd never see the count change! See how clicking this button **does not update its text**: + +```js +import { useRef } from "react"; + +export default function Counter() { + let countRef = useRef(0); + + function handleClick() { + // This doesn't re-render the component! + countRef.current = countRef.current + 1; + } + + return ( + <button on_click={handleClick}> + You clicked {countRef.current} times + </button> + ); +} +``` + +This is why reading `ref.current` during render leads to unreliable code. If you need that, use state instead. + +<DeepDive> + +#### How does useRef work inside? + +Although both `useState` and `useRef` are provided by React, in principle `useRef` could be implemented _on top of_ `useState`. You can imagine that inside of React, `useRef` is implemented like this: + +```js +// Inside of React +function useRef(initialValue) { + const [ref, unused] = useState({ current: initialValue }); + return ref; +} +``` + +During the first render, `useRef` returns `{ current: initialValue }`. This object is stored by React, so during the next render the same object will be returned. Note how the state setter is unused in this example. It is unnecessary because `useRef` always needs to return the same object! + +React provides a built-in version of `useRef` because it is common enough in practice. But you can think of it as a regular state variable without a setter. If you're familiar with object-oriented programming, refs might remind you of instance fields--but instead of `this.something` you write `somethingRef.current`. + +</DeepDive> + +## When to use refs + +Typically, you will use a ref when your component needs to "step outside" React and communicate with external APIs—often a browser API that won't impact the appearance of the component. Here are a few of these rare situations: + +- Storing [timeout IDs](https://developer.mozilla.org/docs/Web/API/setTimeout) +- Storing and manipulating [DOM elements](https://developer.mozilla.org/docs/Web/API/Element), which we cover on [the next page](/learn/manipulating-the-dom-with-refs) +- Storing other objects that aren't necessary to calculate the JSX. + +If your component needs to store some value, but it doesn't impact the rendering logic, choose refs. + +## Best practices for refs + +Following these principles will make your components more predictable: + +- **Treat refs as an escape hatch.** Refs are useful when you work with external systems or browser APIs. If much of your application logic and data flow relies on refs, you might want to rethink your approach. +- **Don't read or write `ref.current` during rendering.** If some information is needed during rendering, use [state](/learn/state-a-components-memory) instead. Since React doesn't know when `ref.current` changes, even reading it while rendering makes your component's behavior difficult to predict. (The only exception to this is code like `if (!ref.current) ref.current = new Thing()` which only sets the ref once during the first render.) + +Limitations of React state don't apply to refs. For example, state acts like a [snapshot for every render](/learn/state-as-a-snapshot) and [doesn't update synchronously.](/learn/queueing-a-series-of-state-updates) But when you mutate the current value of a ref, it changes immediately: + +```js +ref.current = 5; +console.log(ref.current); // 5 +``` + +This is because **the ref itself is a regular JavaScript object,** and so it behaves like one. + +You also don't need to worry about [avoiding mutation](/learn/updating-objects-in-state) when you work with a ref. As long as the object you're mutating isn't used for rendering, React doesn't care what you do with the ref or its contents. + +## Refs and the DOM + +You can point a ref to any value. However, the most common use case for a ref is to access a DOM element. For example, this is handy if you want to focus an input programmatically. When you pass a ref to a `ref` attribute in JSX, like `<div ref={myRef}>`, React will put the corresponding DOM element into `myRef.current`. You can read more about this in [Manipulating the DOM with Refs.](/learn/manipulating-the-dom-with-refs) + +<Recap> + +- Refs are an escape hatch to hold onto values that aren't used for rendering. You won't need them often. +- A ref is a plain JavaScript object with a single property called `current`, which you can read or set. +- You can ask React to give you a ref by calling the `useRef` Hook. +- Like state, refs let you retain information between re-renders of a component. +- Unlike state, setting the ref's `current` value does not trigger a re-render. +- Don't read or write `ref.current` during rendering. This makes your component hard to predict. + +</Recap> + +<Challenges> + +#### Fix a broken chat input + +Type a message and click "Send". You will notice there is a three second delay before you see the "Sent!" alert. During this delay, you can see an "Undo" button. Click it. This "Undo" button is supposed to stop the "Sent!" message from appearing. It does this by calling [`clearTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) for the timeout ID saved during `handleSend`. However, even after "Undo" is clicked, the "Sent!" message still appears. Find why it doesn't work, and fix it. + +<Hint> + +Regular variables like `let timeoutID` don't "survive" between re-renders because every render runs your component (and initializes its variables) from scratch. Should you keep the timeout ID somewhere else? + +</Hint> + +```js +import { useState } from "react"; + +export default function Chat() { + const [text, setText] = useState(""); + const [isSending, setIsSending] = useState(false); + let timeoutID = null; + + function handleSend() { + setIsSending(true); + timeoutID = setTimeout(() => { + alert("Sent!"); + setIsSending(false); + }, 3000); + } + + function handleUndo() { + setIsSending(false); + clearTimeout(timeoutID); + } + + return ( + <> + <input + disabled={isSending} + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button disabled={isSending} on_click={handleSend}> + {isSending ? "Sending..." : "Send"} + </button> + {isSending && <button on_click={handleUndo}>Undo</button>} + </> + ); +} +``` + +<Solution> + +Whenever your component re-renders (such as when you set state), all local variables get initialized from scratch. This is why you can't save the timeout ID in a local variable like `timeoutID` and then expect another event handler to "see" it in the future. Instead, store it in a ref, which React will preserve between renders. + +```js +import { useState, useRef } from "react"; + +export default function Chat() { + const [text, setText] = useState(""); + const [isSending, setIsSending] = useState(false); + const timeoutRef = useRef(null); + + function handleSend() { + setIsSending(true); + timeoutRef.current = setTimeout(() => { + alert("Sent!"); + setIsSending(false); + }, 3000); + } + + function handleUndo() { + setIsSending(false); + clearTimeout(timeoutRef.current); + } + + return ( + <> + <input + disabled={isSending} + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button disabled={isSending} on_click={handleSend}> + {isSending ? "Sending..." : "Send"} + </button> + {isSending && <button on_click={handleUndo}>Undo</button>} + </> + ); +} +``` + +</Solution> + +#### Fix a component failing to re-render + +This button is supposed to toggle between showing "On" and "Off". However, it always shows "Off". What is wrong with this code? Fix it. + +```js +import { useRef } from "react"; + +export default function Toggle() { + const isOnRef = useRef(false); + + return ( + <button + on_click={() => { + isOnRef.current = !isOnRef.current; + }} + > + {isOnRef.current ? "On" : "Off"} + </button> + ); +} +``` + +<Solution> + +In this example, the current value of a ref is used to calculate the rendering output: `{isOnRef.current ? 'On' : 'Off'}`. This is a sign that this information should not be in a ref, and should have instead been put in state. To fix it, remove the ref and use state instead: + +```js +import { useState } from "react"; + +export default function Toggle() { + const [isOn, setIsOn] = useState(false); + + return ( + <button + on_click={() => { + setIsOn(!isOn); + }} + > + {isOn ? "On" : "Off"} + </button> + ); +} +``` + +</Solution> + +#### Fix debouncing + +In this example, all button click handlers are ["debounced".](https://redd.one/blog/debounce-vs-throttle) To see what this means, press one of the buttons. Notice how the message appears a second later. If you press the button while waiting for the message, the timer will reset. So if you keep clicking the same button fast many times, the message won't appear until a second _after_ you stop clicking. Debouncing lets you delay some action until the user "stops doing things". + +This example works, but not quite as intended. The buttons are not independent. To see the problem, click one of the buttons, and then immediately click another button. You'd expect that after a delay, you would see both button's messages. But only the last button's message shows up. The first button's message gets lost. + +Why are the buttons interfering with each other? Find and fix the issue. + +<Hint> + +The last timeout ID variable is shared between all `DebouncedButton` components. This is why clicking one button resets another button's timeout. Can you store a separate timeout ID for each button? + +</Hint> + +```js +let timeoutID; + +function DebouncedButton({ on_click, children }) { + return ( + <button + on_click={() => { + clearTimeout(timeoutID); + timeoutID = setTimeout(() => { + on_click(); + }, 1000); + }} + > + {children} + </button> + ); +} + +export default function Dashboard() { + return ( + <> + <DebouncedButton on_click={() => alert("Spaceship launched!")}> + Launch the spaceship + </DebouncedButton> + <DebouncedButton on_click={() => alert("Soup boiled!")}> + Boil the soup + </DebouncedButton> + <DebouncedButton on_click={() => alert("Lullaby sung!")}> + Sing a lullaby + </DebouncedButton> + </> + ); +} +``` + +```css +button { + display: block; + margin: 10px; +} +``` + +<Solution> + +A variable like `timeoutID` is shared between all components. This is why clicking on the second button resets the first button's pending timeout. To fix this, you can keep timeout in a ref. Each button will get its own ref, so they won't conflict with each other. Notice how clicking two buttons fast will show both messages. + +```js +import { useRef } from "react"; + +function DebouncedButton({ on_click, children }) { + const timeoutRef = useRef(null); + return ( + <button + on_click={() => { + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + on_click(); + }, 1000); + }} + > + {children} + </button> + ); +} + +export default function Dashboard() { + return ( + <> + <DebouncedButton on_click={() => alert("Spaceship launched!")}> + Launch the spaceship + </DebouncedButton> + <DebouncedButton on_click={() => alert("Soup boiled!")}> + Boil the soup + </DebouncedButton> + <DebouncedButton on_click={() => alert("Lullaby sung!")}> + Sing a lullaby + </DebouncedButton> + </> + ); +} +``` + +```css +button { + display: block; + margin: 10px; +} +``` + +</Solution> + +#### Read the latest state + +In this example, after you press "Send", there is a small delay before the message is shown. Type "hello", press Send, and then quickly edit the input again. Despite your edits, the alert would still show "hello" (which was the value of state [at the time](/learn/state-as-a-snapshot#state-over-time) the button was clicked). + +Usually, this behavior is what you want in an app. However, there may be occasional cases where you want some asynchronous code to read the _latest_ version of some state. Can you think of a way to make the alert show the _current_ input text rather than what it was at the time of the click? + +```js +import { useState, useRef } from "react"; + +export default function Chat() { + const [text, setText] = useState(""); + + function handleSend() { + setTimeout(() => { + alert("Sending: " + text); + }, 3000); + } + + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={handleSend}>Send</button> + </> + ); +} +``` + +<Solution> + +State works [like a snapshot](/learn/state-as-a-snapshot), so you can't read the latest state from an asynchronous operation like a timeout. However, you can keep the latest input text in a ref. A ref is mutable, so you can read the `current` property at any time. Since the current text is also used for rendering, in this example, you will need _both_ a state variable (for rendering), _and_ a ref (to read it in the timeout). You will need to update the current ref value manually. + +```js +import { useState, useRef } from "react"; + +export default function Chat() { + const [text, setText] = useState(""); + const textRef = useRef(text); + + function handleChange(e) { + setText(e.target.value); + textRef.current = e.target.value; + } + + function handleSend() { + setTimeout(() => { + alert("Sending: " + textRef.current); + }, 3000); + } + + return ( + <> + <input value={text} onChange={handleChange} /> + <button on_click={handleSend}>Send</button> + </> + ); +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/removing-effect-dependencies.md b/docs/src/learn/removing-effect-dependencies.md new file mode 100644 index 000000000..4c3a6fcc3 --- /dev/null +++ b/docs/src/learn/removing-effect-dependencies.md @@ -0,0 +1,2417 @@ +## Overview + +<p class="intro" markdown> + +When you write an Effect, the linter will verify that you've included every reactive value (like props and state) that the Effect reads in the list of your Effect's dependencies. This ensures that your Effect remains synchronized with the latest props and state of your component. Unnecessary dependencies may cause your Effect to run too often, or even create an infinite loop. Follow this guide to review and remove unnecessary dependencies from your Effects. + +</p> + +!!! summary "You will learn" + + - How to fix infinite Effect dependency loops + - What to do when you want to remove a dependency + - How to read a value from your Effect without "reacting" to it + - How and why to avoid object and function dependencies + - Why suppressing the dependency linter is dangerous, and what to do instead + +## Dependencies should match the code + +When you write an Effect, you first specify how to [start and stop](/learn/lifecycle-of-reactive-effects#the-lifecycle-of-an-effect) whatever you want your Effect to be doing: + +```js +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + // ... +} +``` + +Then, if you leave the Effect dependencies empty (`[]`), the linter will suggest the correct dependencies: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // <-- Fix the mistake here! + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Fill them in according to what the linter says: + +```js +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +} +``` + +[Effects "react" to reactive values.](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) Since `roomId` is a reactive value (it can change due to a re-render), the linter verifies that you've specified it as a dependency. If `roomId` receives a different value, React will re-synchronize your Effect. This ensures that the chat stays connected to the selected room and "reacts" to the dropdown: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +### To remove a dependency, prove that it's not a dependency + +Notice that you can't "choose" the dependencies of your Effect. Every <CodeStep step={2}>reactive value</CodeStep> used by your Effect's code must be declared in your dependency list. The dependency list is determined by the surrounding code: + +```js +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + // This is a reactive value + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // This Effect reads that reactive value + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ So you must specify that reactive value as a dependency of your Effect + // ... +} +``` + +[Reactive values](/learn/lifecycle-of-reactive-effects#all-variables-declared-in-the-component-body-are-reactive) include props and all variables and functions declared directly inside of your component. Since `roomId` is a reactive value, you can't remove it from the dependency list. The linter wouldn't allow it: + +```js +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // 🔴 React Hook useEffect has a missing dependency: 'roomId' + // ... +} +``` + +And the linter would be right! Since `roomId` may change over time, this would introduce a bug in your code. + +**To remove a dependency, "prove" to the linter that it _doesn't need_ to be a dependency.** For example, you can move `roomId` out of your component to prove that it's not reactive and won't change on re-renders: + +```js +const serverUrl = "https://localhost:1234"; +const roomId = "music"; // Not a reactive value anymore + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // ✅ All dependencies declared + // ... +} +``` + +Now that `roomId` is not a reactive value (and can't change on a re-render), it doesn't need to be a dependency: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; +const roomId = "music"; + +export default function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +This is why you could now specify an [empty (`[]`) dependency list.](/learn/lifecycle-of-reactive-effects#what-an-effect-with-empty-dependencies-means) Your Effect _really doesn't_ depend on any reactive value anymore, so it _really doesn't_ need to re-run when any of the component's props or state change. + +### To change the dependencies, change the code + +You might have noticed a pattern in your workflow: + +1. First, you **change the code** of your Effect or how your reactive values are declared. +2. Then, you follow the linter and adjust the dependencies to **match the code you have changed.** +3. If you're not happy with the list of dependencies, you **go back to the first step** (and change the code again). + +The last part is important. **If you want to change the dependencies, change the surrounding code first.** You can think of the dependency list as [a list of all the reactive values used by your Effect's code.](/learn/lifecycle-of-reactive-effects#react-verifies-that-you-specified-every-reactive-value-as-a-dependency) You don't _choose_ what to put on that list. The list _describes_ your code. To change the dependency list, change the code. + +This might feel like solving an equation. You might start with a goal (for example, to remove a dependency), and you need to "find" the code matching that goal. Not everyone finds solving equations fun, and the same thing could be said about writing Effects! Luckily, there is a list of common recipes that you can try below. + +<Pitfall> + +If you have an existing codebase, you might have some Effects that suppress the linter like this: + +```js +useEffect(() => { + // ... + // 🔴 Avoid suppressing the linter like this: + // eslint-ignore-next-line react-hooks/exhaustive-deps +}, []); +``` + +**When dependencies don't match the code, there is a very high risk of introducing bugs.** By suppressing the linter, you "lie" to React about the values your Effect depends on. + +Instead, use the techniques below. + +</Pitfall> + +<DeepDive> + +#### Why is suppressing the dependency linter so dangerous? + +Suppressing the linter leads to very unintuitive bugs that are hard to find and fix. Here's one example: + +```js +import { useState, useEffect } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + + function onTick() { + setCount(count + increment); + } + + useEffect(() => { + const id = setInterval(onTick, 1000); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Every second, increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +Let's say that you wanted to run the Effect "only on mount". You've read that [empty (`[]`) dependencies](/learn/lifecycle-of-reactive-effects#what-an-effect-with-empty-dependencies-means) do that, so you've decided to ignore the linter, and forcefully specified `[]` as the dependencies. + +This counter was supposed to increment every second by the amount configurable with the two buttons. However, since you "lied" to React that this Effect doesn't depend on anything, React forever keeps using the `onTick` function from the initial render. [During that render,](/learn/state-as-a-snapshot#rendering-takes-a-snapshot-in-time) `count` was `0` and `increment` was `1`. This is why `onTick` from that render always calls `setCount(0 + 1)` every second, and you always see `1`. Bugs like this are harder to fix when they're spread across multiple components. + +There's always a better solution than ignoring the linter! To fix this code, you need to add `onTick` to the dependency list. (To ensure the interval is only setup once, [make `onTick` an Effect Event.](/learn/separating-events-from-effects#reading-latest-props-and-state-with-effect-events)) + +**We recommend treating the dependency lint error as a compilation error. If you don't suppress it, you will never see bugs like this.** The rest of this page documents the alternatives for this and other cases. + +</DeepDive> + +## Removing unnecessary dependencies + +Every time you adjust the Effect's dependencies to reflect the code, look at the dependency list. Does it make sense for the Effect to re-run when any of these dependencies change? Sometimes, the answer is "no": + +- You might want to re-execute _different parts_ of your Effect under different conditions. +- You might want to only read the _latest value_ of some dependency instead of "reacting" to its changes. +- A dependency may change too often _unintentionally_ because it's an object or a function. + +To find the right solution, you'll need to answer a few questions about your Effect. Let's walk through them. + +### Should this code move to an event handler? + +The first thing you should think about is whether this code should be an Effect at all. + +Imagine a form. On submit, you set the `submitted` state variable to `true`. You need to send a POST request and show a notification. You've put this logic inside an Effect that "reacts" to `submitted` being `true`: + +```js +function Form() { + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (submitted) { + // 🔴 Avoid: Event-specific logic inside an Effect + post("/api/register"); + showNotification("Successfully registered!"); + } + }, [submitted]); + + function handleSubmit() { + setSubmitted(true); + } + + // ... +} +``` + +Later, you want to style the notification message according to the current theme, so you read the current theme. Since `theme` is declared in the component body, it is a reactive value, so you add it as a dependency: + +```js +function Form() { + const [submitted, setSubmitted] = useState(false); + const theme = useContext(ThemeContext); + + useEffect(() => { + if (submitted) { + // 🔴 Avoid: Event-specific logic inside an Effect + post("/api/register"); + showNotification("Successfully registered!", theme); + } + }, [submitted, theme]); // ✅ All dependencies declared + + function handleSubmit() { + setSubmitted(true); + } + + // ... +} +``` + +By doing this, you've introduced a bug. Imagine you submit the form first and then switch between Dark and Light themes. The `theme` will change, the Effect will re-run, and so it will display the same notification again! + +**The problem here is that this shouldn't be an Effect in the first place.** You want to send this POST request and show the notification in response to _submitting the form,_ which is a particular interaction. To run some code in response to particular interaction, put that logic directly into the corresponding event handler: + +```js +function Form() { + const theme = useContext(ThemeContext); + + function handleSubmit() { + // ✅ Good: Event-specific logic is called from event handlers + post("/api/register"); + showNotification("Successfully registered!", theme); + } + + // ... +} +``` + +Now that the code is in an event handler, it's not reactive--so it will only run when the user submits the form. Read more about [choosing between event handlers and Effects](/learn/separating-events-from-effects#reactive-values-and-reactive-logic) and [how to delete unnecessary Effects.](/learn/you-might-not-need-an-effect) + +### Is your Effect doing several unrelated things? + +The next question you should ask yourself is whether your Effect is doing several unrelated things. + +Imagine you're creating a shipping form where the user needs to choose their city and area. You fetch the list of `cities` from the server according to the selected `country` to show them in a dropdown: + +```js +function ShippingForm({ country }) { + const [cities, setCities] = useState(null); + const [city, setCity] = useState(null); + + useEffect(() => { + let ignore = false; + fetch(`/api/cities?country=${country}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setCities(json); + } + }); + return () => { + ignore = true; + }; + }, [country]); // ✅ All dependencies declared + + // ... +``` + +This is a good example of [fetching data in an Effect.](/learn/you-might-not-need-an-effect#fetching-data) You are synchronizing the `cities` state with the network according to the `country` prop. You can't do this in an event handler because you need to fetch as soon as `ShippingForm` is displayed and whenever the `country` changes (no matter which interaction causes it). + +Now let's say you're adding a second select box for city areas, which should fetch the `areas` for the currently selected `city`. You might start by adding a second `fetch` call for the list of areas inside the same Effect: + +```js +function ShippingForm({ country }) { + const [cities, setCities] = useState(null); + const [city, setCity] = useState(null); + const [areas, setAreas] = useState(null); + + useEffect(() => { + let ignore = false; + fetch(`/api/cities?country=${country}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setCities(json); + } + }); + // 🔴 Avoid: A single Effect synchronizes two independent processes + if (city) { + fetch(`/api/areas?city=${city}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setAreas(json); + } + }); + } + return () => { + ignore = true; + }; + }, [country, city]); // ✅ All dependencies declared + + // ... +``` + +However, since the Effect now uses the `city` state variable, you've had to add `city` to the list of dependencies. That, in turn, introduced a problem: when the user selects a different city, the Effect will re-run and call `fetchCities(country)`. As a result, you will be unnecessarily refetching the list of cities many times. + +**The problem with this code is that you're synchronizing two different unrelated things:** + +1. You want to synchronize the `cities` state to the network based on the `country` prop. +1. You want to synchronize the `areas` state to the network based on the `city` state. + +Split the logic into two Effects, each of which reacts to the prop that it needs to synchronize with: + +```js +function ShippingForm({ country }) { + const [cities, setCities] = useState(null); + useEffect(() => { + let ignore = false; + fetch(`/api/cities?country=${country}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setCities(json); + } + }); + return () => { + ignore = true; + }; + }, [country]); // ✅ All dependencies declared + + const [city, setCity] = useState(null); + const [areas, setAreas] = useState(null); + useEffect(() => { + if (city) { + let ignore = false; + fetch(`/api/areas?city=${city}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setAreas(json); + } + }); + return () => { + ignore = true; + }; + } + }, [city]); // ✅ All dependencies declared + + // ... +``` + +Now the first Effect only re-runs if the `country` changes, while the second Effect re-runs when the `city` changes. You've separated them by purpose: two different things are synchronized by two separate Effects. Two separate Effects have two separate dependency lists, so they won't trigger each other unintentionally. + +The final code is longer than the original, but splitting these Effects is still correct. [Each Effect should represent an independent synchronization process.](/learn/lifecycle-of-reactive-effects#each-effect-represents-a-separate-synchronization-process) In this example, deleting one Effect doesn't break the other Effect's logic. This means they _synchronize different things,_ and it's good to split them up. If you're concerned about duplication, you can improve this code by [extracting repetitive logic into a custom Hook.](/learn/reusing-logic-with-custom-hooks#when-to-use-custom-hooks) + +### Are you reading some state to calculate the next state? + +This Effect updates the `messages` state variable with a newly created array every time a new message arrives: + +```js +function ChatRoom({ roomId }) { + const [messages, setMessages] = useState([]); + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + setMessages([...messages, receivedMessage]); + }); + // ... +``` + +It uses the `messages` variable to [create a new array](/learn/updating-arrays-in-state) starting with all the existing messages and adds the new message at the end. However, since `messages` is a reactive value read by an Effect, it must be a dependency: + +```js +function ChatRoom({ roomId }) { + const [messages, setMessages] = useState([]); + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + setMessages([...messages, receivedMessage]); + }); + return () => connection.disconnect(); + }, [roomId, messages]); // ✅ All dependencies declared + // ... +``` + +And making `messages` a dependency introduces a problem. + +Every time you receive a message, `setMessages()` causes the component to re-render with a new `messages` array that includes the received message. However, since this Effect now depends on `messages`, this will _also_ re-synchronize the Effect. So every new message will make the chat re-connect. The user would not like that! + +To fix the issue, don't read `messages` inside the Effect. Instead, pass an [updater function](/reference/react/useState#updating-state-based-on-the-previous-state) to `setMessages`: + +```js +function ChatRoom({ roomId }) { + const [messages, setMessages] = useState([]); + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + setMessages(msgs => [...msgs, receivedMessage]); + }); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +``` + +**Notice how your Effect does not read the `messages` variable at all now.** You only need to pass an updater function like `msgs => [...msgs, receivedMessage]`. React [puts your updater function in a queue](/learn/queueing-a-series-of-state-updates) and will provide the `msgs` argument to it during the next render. This is why the Effect itself doesn't need to depend on `messages` anymore. As a result of this fix, receiving a chat message will no longer make the chat re-connect. + +### Do you want to read a value without "reacting" to its changes? + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +Suppose that you want to play a sound when the user receives a new message unless `isMuted` is `true`: + +```js +function ChatRoom({ roomId }) { + const [messages, setMessages] = useState([]); + const [isMuted, setIsMuted] = useState(false); + + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + setMessages(msgs => [...msgs, receivedMessage]); + if (!isMuted) { + playSound(); + } + }); + // ... +``` + +Since your Effect now uses `isMuted` in its code, you have to add it to the dependencies: + +```js +function ChatRoom({ roomId }) { + const [messages, setMessages] = useState([]); + const [isMuted, setIsMuted] = useState(false); + + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + setMessages(msgs => [...msgs, receivedMessage]); + if (!isMuted) { + playSound(); + } + }); + return () => connection.disconnect(); + }, [roomId, isMuted]); // ✅ All dependencies declared + // ... +``` + +The problem is that every time `isMuted` changes (for example, when the user presses the "Muted" toggle), the Effect will re-synchronize, and reconnect to the chat. This is not the desired user experience! (In this example, even disabling the linter would not work--if you do that, `isMuted` would get "stuck" with its old value.) + +To solve this problem, you need to extract the logic that shouldn't be reactive out of the Effect. You don't want this Effect to "react" to the changes in `isMuted`. [Move this non-reactive piece of logic into an Effect Event:](/learn/separating-events-from-effects#declaring-an-effect-event) + +```js +import { useState, useEffect, useEffectEvent } from 'react'; + +function ChatRoom({ roomId }) { + const [messages, setMessages] = useState([]); + const [isMuted, setIsMuted] = useState(false); + + const onMessage = useEffectEvent(receivedMessage => { + setMessages(msgs => [...msgs, receivedMessage]); + if (!isMuted) { + playSound(); + } + }); + + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + onMessage(receivedMessage); + }); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +``` + +Effect Events let you split an Effect into reactive parts (which should "react" to reactive values like `roomId` and their changes) and non-reactive parts (which only read their latest values, like `onMessage` reads `isMuted`). **Now that you read `isMuted` inside an Effect Event, it doesn't need to be a dependency of your Effect.** As a result, the chat won't re-connect when you toggle the "Muted" setting on and off, solving the original issue! + +#### Wrapping an event handler from the props + +You might run into a similar problem when your component receives an event handler as a prop: + +```js +function ChatRoom({ roomId, onReceiveMessage }) { + const [messages, setMessages] = useState([]); + + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + onReceiveMessage(receivedMessage); + }); + return () => connection.disconnect(); + }, [roomId, onReceiveMessage]); // ✅ All dependencies declared + // ... +``` + +Suppose that the parent component passes a _different_ `onReceiveMessage` function on every render: + +```js +<ChatRoom + roomId={roomId} + onReceiveMessage={(receivedMessage) => { + // ... + }} +/> +``` + +Since `onReceiveMessage` is a dependency, it would cause the Effect to re-synchronize after every parent re-render. This would make it re-connect to the chat. To solve this, wrap the call in an Effect Event: + +```js +function ChatRoom({ roomId, onReceiveMessage }) { + const [messages, setMessages] = useState([]); + + const onMessage = useEffectEvent(receivedMessage => { + onReceiveMessage(receivedMessage); + }); + + useEffect(() => { + const connection = createConnection(); + connection.connect(); + connection.on('message', (receivedMessage) => { + onMessage(receivedMessage); + }); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +``` + +Effect Events aren't reactive, so you don't need to specify them as dependencies. As a result, the chat will no longer re-connect even if the parent component passes a function that's different on every re-render. + +#### Separating reactive and non-reactive code + +In this example, you want to log a visit every time `roomId` changes. You want to include the current `notificationCount` with every log, but you _don't_ want a change to `notificationCount` to trigger a log event. + +The solution is again to split out the non-reactive code into an Effect Event: + +```js +function Chat({ roomId, notificationCount }) { + const onVisit = useEffectEvent((visitedRoomId) => { + logVisit(visitedRoomId, notificationCount); + }); + + useEffect(() => { + onVisit(roomId); + }, [roomId]); // ✅ All dependencies declared + // ... +} +``` + +You want your logic to be reactive with regards to `roomId`, so you read `roomId` inside of your Effect. However, you don't want a change to `notificationCount` to log an extra visit, so you read `notificationCount` inside of the Effect Event. [Learn more about reading the latest props and state from Effects using Effect Events.](/learn/separating-events-from-effects#reading-latest-props-and-state-with-effect-events) + +### Does some reactive value change unintentionally? + +Sometimes, you _do_ want your Effect to "react" to a certain value, but that value changes more often than you'd like--and might not reflect any actual change from the user's perspective. For example, let's say that you create an `options` object in the body of your component, and then read that object from inside of your Effect: + +```js +function ChatRoom({ roomId }) { + // ... + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + + useEffect(() => { + const connection = createConnection(options); + connection.connect(); + // ... +``` + +This object is declared in the component body, so it's a [reactive value.](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) When you read a reactive value like this inside an Effect, you declare it as a dependency. This ensures your Effect "reacts" to its changes: + +```js +// ... +useEffect(() => { + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); +}, [options]); // ✅ All dependencies declared +// ... +``` + +It is important to declare it as a dependency! This ensures, for example, that if the `roomId` changes, your Effect will re-connect to the chat with the new `options`. However, there is also a problem with the code above. To see it, try typing into the input in the sandbox below, and watch what happens in the console: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + // Temporarily disable the linter to demonstrate the problem + // eslint-disable-next-line react-hooks/exhaustive-deps + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + + useEffect(() => { + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [options]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +In the sandbox above, the input only updates the `message` state variable. From the user's perspective, this should not affect the chat connection. However, every time you update the `message`, your component re-renders. When your component re-renders, the code inside of it runs again from scratch. + +A new `options` object is created from scratch on every re-render of the `ChatRoom` component. React sees that the `options` object is a _different object_ from the `options` object created during the last render. This is why it re-synchronizes your Effect (which depends on `options`), and the chat re-connects as you type. + +**This problem only affects objects and functions. In JavaScript, each newly created object and function is considered distinct from all the others. It doesn't matter that the contents inside of them may be the same!** + +```js +// During the first render +const options1 = { serverUrl: "https://localhost:1234", roomId: "music" }; + +// During the next render +const options2 = { serverUrl: "https://localhost:1234", roomId: "music" }; + +// These are two different objects! +console.log(Object.is(options1, options2)); // false +``` + +**Object and function dependencies can make your Effect re-synchronize more often than you need.** + +This is why, whenever possible, you should try to avoid objects and functions as your Effect's dependencies. Instead, try moving them outside the component, inside the Effect, or extracting primitive values out of them. + +#### Move static objects and functions outside your component + +If the object does not depend on any props and state, you can move that object outside your component: + +```js +const options = { + serverUrl: 'https://localhost:1234', + roomId: 'music' +}; + +function ChatRoom() { + const [message, setMessage] = useState(''); + + useEffect(() => { + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, []); // ✅ All dependencies declared + // ... +``` + +This way, you _prove_ to the linter that it's not reactive. It can't change as a result of a re-render, so it doesn't need to be a dependency. Now re-rendering `ChatRoom` won't cause your Effect to re-synchronize. + +This works for functions too: + +```js +function createOptions() { + return { + serverUrl: 'https://localhost:1234', + roomId: 'music' + }; +} + +function ChatRoom() { + const [message, setMessage] = useState(''); + + useEffect(() => { + const options = createOptions(); + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); + }, []); // ✅ All dependencies declared + // ... +``` + +Since `createOptions` is declared outside your component, it's not a reactive value. This is why it doesn't need to be specified in your Effect's dependencies, and why it won't ever cause your Effect to re-synchronize. + +#### Move dynamic objects and functions inside your Effect + +If your object depends on some reactive value that may change as a result of a re-render, like a `roomId` prop, you can't pull it _outside_ your component. You can, however, move its creation _inside_ of your Effect's code: + +```js +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +``` + +Now that `options` is declared inside of your Effect, it is no longer a dependency of your Effect. Instead, the only reactive value used by your Effect is `roomId`. Since `roomId` is not an object or function, you can be sure that it won't be _unintentionally_ different. In JavaScript, numbers and strings are compared by their content: + +```js +// During the first render +const roomId1 = "music"; + +// During the next render +const roomId2 = "music"; + +// These two strings are the same! +console.log(Object.is(roomId1, roomId2)); // true +``` + +Thanks to this fix, the chat no longer re-connects if you edit the input: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +However, it _does_ re-connect when you change the `roomId` dropdown, as you would expect. + +This works for functions, too: + +```js +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + useEffect(() => { + function createOptions() { + return { + serverUrl: serverUrl, + roomId: roomId + }; + } + + const options = createOptions(); + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +``` + +You can write your own functions to group pieces of logic inside your Effect. As long as you also declare them _inside_ your Effect, they're not reactive values, and so they don't need to be dependencies of your Effect. + +#### Read primitive values from objects + +Sometimes, you may receive an object from props: + +```js +function ChatRoom({ options }) { + const [message, setMessage] = useState(''); + + useEffect(() => { + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [options]); // ✅ All dependencies declared + // ... +``` + +The risk here is that the parent component will create the object during rendering: + +```js +<ChatRoom + roomId={roomId} + options={{ + serverUrl: serverUrl, + roomId: roomId, + }} +/> +``` + +This would cause your Effect to re-connect every time the parent component re-renders. To fix this, read information from the object _outside_ the Effect, and avoid having object and function dependencies: + +```js +function ChatRoom({ options }) { + const [message, setMessage] = useState(''); + + const { roomId, serverUrl } = options; + useEffect(() => { + const connection = createConnection({ + roomId: roomId, + serverUrl: serverUrl + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); // ✅ All dependencies declared + // ... +``` + +The logic gets a little repetitive (you read some values from an object outside an Effect, and then create an object with the same values inside the Effect). But it makes it very explicit what information your Effect _actually_ depends on. If an object is re-created unintentionally by the parent component, the chat would not re-connect. However, if `options.roomId` or `options.serverUrl` really are different, the chat would re-connect. + +#### Calculate primitive values from functions + +The same approach can work for functions. For example, suppose the parent component passes a function: + +```js +<ChatRoom + roomId={roomId} + getOptions={() => { + return { + serverUrl: serverUrl, + roomId: roomId, + }; + }} +/> +``` + +To avoid making it a dependency (and causing it to re-connect on re-renders), call it outside the Effect. This gives you the `roomId` and `serverUrl` values that aren't objects, and that you can read from inside your Effect: + +```js +function ChatRoom({ getOptions }) { + const [message, setMessage] = useState(''); + + const { roomId, serverUrl } = getOptions(); + useEffect(() => { + const connection = createConnection({ + roomId: roomId, + serverUrl: serverUrl + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); // ✅ All dependencies declared + // ... +``` + +This only works for [pure](/learn/keeping-components-pure) functions because they are safe to call during rendering. If your function is an event handler, but you don't want its changes to re-synchronize your Effect, [wrap it into an Effect Event instead.](#do-you-want-to-read-a-value-without-reacting-to-its-changes) + +<Recap> + +- Dependencies should always match the code. +- When you're not happy with your dependencies, what you need to edit is the code. +- Suppressing the linter leads to very confusing bugs, and you should always avoid it. +- To remove a dependency, you need to "prove" to the linter that it's not necessary. +- If some code should run in response to a specific interaction, move that code to an event handler. +- If different parts of your Effect should re-run for different reasons, split it into several Effects. +- If you want to update some state based on the previous state, pass an updater function. +- If you want to read the latest value without "reacting" it, extract an Effect Event from your Effect. +- In JavaScript, objects and functions are considered different if they were created at different times. +- Try to avoid object and function dependencies. Move them outside the component or inside the Effect. + +</Recap> + +<Challenges> + +#### Fix a resetting interval + +This Effect sets up an interval that ticks every second. You've noticed something strange happening: it seems like the interval gets destroyed and re-created every time it ticks. Fix the code so that the interval doesn't get constantly re-created. + +<Hint> + +It seems like this Effect's code depends on `count`. Is there some way to not need this dependency? There should be a way to update the `count` state based on its previous value without adding a dependency on that value. + +</Hint> + +```js +import { useState, useEffect } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + + useEffect(() => { + console.log("✅ Creating an interval"); + const id = setInterval(() => { + console.log("⏰ Interval tick"); + setCount(count + 1); + }, 1000); + return () => { + console.log("❌ Clearing an interval"); + clearInterval(id); + }; + }, [count]); + + return <h1>Counter: {count}</h1>; +} +``` + +<Solution> + +You want to update the `count` state to be `count + 1` from inside the Effect. However, this makes your Effect depend on `count`, which changes with every tick, and that's why your interval gets re-created on every tick. + +To solve this, use the [updater function](/reference/react/useState#updating-state-based-on-the-previous-state) and write `setCount(c => c + 1)` instead of `setCount(count + 1)`: + +```js +import { useState, useEffect } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + + useEffect(() => { + console.log("✅ Creating an interval"); + const id = setInterval(() => { + console.log("⏰ Interval tick"); + setCount((c) => c + 1); + }, 1000); + return () => { + console.log("❌ Clearing an interval"); + clearInterval(id); + }; + }, []); + + return <h1>Counter: {count}</h1>; +} +``` + +Instead of reading `count` inside the Effect, you pass a `c => c + 1` instruction ("increment this number!") to React. React will apply it on the next render. And since you don't need to read the value of `count` inside your Effect anymore, so you can keep your Effect's dependencies empty (`[]`). This prevents your Effect from re-creating the interval on every tick. + +</Solution> + +#### Fix a retriggering animation + +In this example, when you press "Show", a welcome message fades in. The animation takes a second. When you press "Remove", the welcome message immediately disappears. The logic for the fade-in animation is implemented in the `animation.js` file as plain JavaScript [animation loop.](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) You don't need to change that logic. You can treat it as a third-party library. Your Effect creates an instance of `FadeInAnimation` for the DOM node, and then calls `start(duration)` or `stop()` to control the animation. The `duration` is controlled by a slider. Adjust the slider and see how the animation changes. + +This code already works, but there is something you want to change. Currently, when you move the slider that controls the `duration` state variable, it retriggers the animation. Change the behavior so that the Effect does not "react" to the `duration` variable. When you press "Show", the Effect should use the current `duration` on the slider. However, moving the slider itself should not by itself retrigger the animation. + +<Hint> + +Is there a line of code inside the Effect that should not be reactive? How can you move non-reactive code out of the Effect? + +</Hint> + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect, useRef } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { FadeInAnimation } from "./animation.js"; + +function Welcome({ duration }) { + const ref = useRef(null); + + useEffect(() => { + const animation = new FadeInAnimation(ref.current); + animation.start(duration); + return () => { + animation.stop(); + }; + }, [duration]); + + return ( + <h1 + ref={ref} + style={{ + opacity: 0, + color: "white", + padding: 50, + textAlign: "center", + fontSize: 50, + backgroundImage: + "radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%)", + }} + > + Welcome + </h1> + ); +} + +export default function App() { + const [duration, setDuration] = useState(1000); + const [show, setShow] = useState(false); + + return ( + <> + <label> + <input + type="range" + min="100" + max="3000" + value={duration} + onChange={(e) => setDuration(Number(e.target.value))} + /> + <br /> + Fade in duration: {duration} ms + </label> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome duration={duration} />} + </> + ); +} +``` + +```js +export class FadeInAnimation { + constructor(node) { + this.node = node; + } + start(duration) { + this.duration = duration; + if (this.duration === 0) { + // Jump to end immediately + this.onProgress(1); + } else { + this.onProgress(0); + // Start animating + this.startTime = performance.now(); + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onFrame() { + const timePassed = performance.now() - this.startTime; + const progress = Math.min(timePassed / this.duration, 1); + this.onProgress(progress); + if (progress < 1) { + // We still have more frames to paint + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onProgress(progress) { + this.node.style.opacity = progress; + } + stop() { + cancelAnimationFrame(this.frameId); + this.startTime = null; + this.frameId = null; + this.duration = 0; + } +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +``` + +<Solution> + +Your Effect needs to read the latest value of `duration`, but you don't want it to "react" to changes in `duration`. You use `duration` to start the animation, but starting animation isn't reactive. Extract the non-reactive line of code into an Effect Event, and call that function from your Effect. + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect, useRef } from "react"; +import { FadeInAnimation } from "./animation.js"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +function Welcome({ duration }) { + const ref = useRef(null); + + const onAppear = useEffectEvent((animation) => { + animation.start(duration); + }); + + useEffect(() => { + const animation = new FadeInAnimation(ref.current); + onAppear(animation); + return () => { + animation.stop(); + }; + }, []); + + return ( + <h1 + ref={ref} + style={{ + opacity: 0, + color: "white", + padding: 50, + textAlign: "center", + fontSize: 50, + backgroundImage: + "radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%)", + }} + > + Welcome + </h1> + ); +} + +export default function App() { + const [duration, setDuration] = useState(1000); + const [show, setShow] = useState(false); + + return ( + <> + <label> + <input + type="range" + min="100" + max="3000" + value={duration} + onChange={(e) => setDuration(Number(e.target.value))} + /> + <br /> + Fade in duration: {duration} ms + </label> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome duration={duration} />} + </> + ); +} +``` + +```js +export class FadeInAnimation { + constructor(node) { + this.node = node; + } + start(duration) { + this.duration = duration; + this.onProgress(0); + this.startTime = performance.now(); + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + onFrame() { + const timePassed = performance.now() - this.startTime; + const progress = Math.min(timePassed / this.duration, 1); + this.onProgress(progress); + if (progress < 1) { + // We still have more frames to paint + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onProgress(progress) { + this.node.style.opacity = progress; + } + stop() { + cancelAnimationFrame(this.frameId); + this.startTime = null; + this.frameId = null; + this.duration = 0; + } +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +``` + +Effect Events like `onAppear` are not reactive, so you can read `duration` inside without retriggering the animation. + +</Solution> + +#### Fix a reconnecting chat + +In this example, every time you press "Toggle theme", the chat re-connects. Why does this happen? Fix the mistake so that the chat re-connects only when you edit the Server URL or choose a different chat room. + +Treat `chat.js` as an external third-party library: you can consult it to check its API, but don't edit it. + +<Hint> + +There's more than one way to fix this, but ultimately you want to avoid having an object as your dependency. + +</Hint> + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + const [roomId, setRoomId] = useState("general"); + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + + return ( + <div className={isDark ? "dark" : "light"}> + <button on_click={() => setIsDark(!isDark)}>Toggle theme</button> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom options={options} /> + </div> + ); +} +``` + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export default function ChatRoom({ options }) { + useEffect(() => { + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [options]); + + return <h1>Welcome to the {options.roomId} room!</h1>; +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +label, +button { + display: block; + margin-bottom: 5px; +} +.dark { + background: #222; + color: #eee; +} +``` + +<Solution> + +Your Effect is re-running because it depends on the `options` object. Objects can be re-created unintentionally, you should try to avoid them as dependencies of your Effects whenever possible. + +The least invasive fix is to read `roomId` and `serverUrl` right outside the Effect, and then make the Effect depend on those primitive values (which can't change unintentionally). Inside the Effect, create an object and it pass to `createConnection`: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + const [roomId, setRoomId] = useState("general"); + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + + return ( + <div className={isDark ? "dark" : "light"}> + <button on_click={() => setIsDark(!isDark)}>Toggle theme</button> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom options={options} /> + </div> + ); +} +``` + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export default function ChatRoom({ options }) { + const { roomId, serverUrl } = options; + useEffect(() => { + const connection = createConnection({ + roomId: roomId, + serverUrl: serverUrl, + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); + + return <h1>Welcome to the {options.roomId} room!</h1>; +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +label, +button { + display: block; + margin-bottom: 5px; +} +.dark { + background: #222; + color: #eee; +} +``` + +It would be even better to replace the object `options` prop with the more specific `roomId` and `serverUrl` props: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + const [roomId, setRoomId] = useState("general"); + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + return ( + <div className={isDark ? "dark" : "light"}> + <button on_click={() => setIsDark(!isDark)}>Toggle theme</button> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} serverUrl={serverUrl} /> + </div> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export default function ChatRoom({ roomId, serverUrl }) { + useEffect(() => { + const connection = createConnection({ + roomId: roomId, + serverUrl: serverUrl, + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); + + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +label, +button { + display: block; + margin-bottom: 5px; +} +.dark { + background: #222; + color: #eee; +} +``` + +Sticking to primitive props where possible makes it easier to optimize your components later. + +</Solution> + +#### Fix a reconnecting chat, again + +This example connects to the chat either with or without encryption. Toggle the checkbox and notice the different messages in the console when the encryption is on and off. Try changing the room. Then, try toggling the theme. When you're connected to a chat room, you will receive new messages every few seconds. Verify that their color matches the theme you've picked. + +In this example, the chat re-connects every time you try to change the theme. Fix this. After the fix, changing the theme should not re-connect the chat, but toggling encryption settings or changing the room should re-connect. + +Don't change any code in `chat.js`. Other than that, you can change any code as long as it results in the same behavior. For example, you may find it helpful to change which props are being passed down. + +<Hint> + +You're passing down two functions: `onMessage` and `createConnection`. Both of them are created from scratch every time `App` re-renders. They are considered to be new values every time, which is why they re-trigger your Effect. + +One of these functions is an event handler. Do you know some way to call an event handler an Effect without "reacting" to the new values of the event handler function? That would come in handy! + +Another of these functions only exists to pass some state to an imported API method. Is this function really necessary? What is the essential information that's being passed down? You might need to move some imports from `App.js` to `ChatRoom.js`. + +</Hint> + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from "./chat.js"; +import { showNotification } from "./notifications.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + const [roomId, setRoomId] = useState("general"); + const [isEncrypted, setIsEncrypted] = useState(false); + + return ( + <> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <label> + <input + type="checkbox" + checked={isEncrypted} + onChange={(e) => setIsEncrypted(e.target.checked)} + /> + Enable encryption + </label> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom + roomId={roomId} + onMessage={(msg) => { + showNotification( + "New message: " + msg, + isDark ? "dark" : "light" + ); + }} + createConnection={() => { + const options = { + serverUrl: "https://localhost:1234", + roomId: roomId, + }; + if (isEncrypted) { + return createEncryptedConnection(options); + } else { + return createUnencryptedConnection(options); + } + }} + /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export default function ChatRoom({ roomId, createConnection, onMessage }) { + useEffect(() => { + const connection = createConnection(); + connection.on("message", (msg) => onMessage(msg)); + connection.connect(); + return () => connection.disconnect(); + }, [createConnection, onMessage]); + + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createEncryptedConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ 🔐 Connecting to "' + roomId + '" room... (encrypted)' + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)' + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} + +export function createUnencryptedConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room (unencrypted)...' + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ Disconnected from "' + roomId + '" room (unencrypted)' + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label, +button { + display: block; + margin-bottom: 5px; +} +``` + +<Solution> + +There's more than one correct way to solve this, but here is one possible solution. + +In the original example, toggling the theme caused different `onMessage` and `createConnection` functions to be created and passed down. Since the Effect depended on these functions, the chat would re-connect every time you toggle the theme. + +To fix the problem with `onMessage`, you needed to wrap it into an Effect Event: + +```js +export default function ChatRoom({ roomId, createConnection, onMessage }) { + const onReceiveMessage = useEffectEvent(onMessage); + + useEffect(() => { + const connection = createConnection(); + connection.on('message', (msg) => onReceiveMessage(msg)); + // ... +``` + +Unlike the `onMessage` prop, the `onReceiveMessage` Effect Event is not reactive. This is why it doesn't need to be a dependency of your Effect. As a result, changes to `onMessage` won't cause the chat to re-connect. + +You can't do the same with `createConnection` because it _should_ be reactive. You _want_ the Effect to re-trigger if the user switches between an encrypted and an unencryption connection, or if the user switches the current room. However, because `createConnection` is a function, you can't check whether the information it reads has _actually_ changed or not. To solve this, instead of passing `createConnection` down from the `App` component, pass the raw `roomId` and `isEncrypted` values: + +```js +<ChatRoom + roomId={roomId} + isEncrypted={isEncrypted} + onMessage={(msg) => { + showNotification("New message: " + msg, isDark ? "dark" : "light"); + }} +/> +``` + +Now you can move the `createConnection` function _inside_ the Effect instead of passing it down from the `App`: + +```js +import { + createEncryptedConnection, + createUnencryptedConnection, +} from './chat.js'; + +export default function ChatRoom({ roomId, isEncrypted, onMessage }) { + const onReceiveMessage = useEffectEvent(onMessage); + + useEffect(() => { + function createConnection() { + const options = { + serverUrl: 'https://localhost:1234', + roomId: roomId + }; + if (isEncrypted) { + return createEncryptedConnection(options); + } else { + return createUnencryptedConnection(options); + } + } + // ... +``` + +After these two changes, your Effect no longer depends on any function values: + +```js +export default function ChatRoom({ roomId, isEncrypted, onMessage }) { // Reactive values + const onReceiveMessage = useEffectEvent(onMessage); // Not reactive + + useEffect(() => { + function createConnection() { + const options = { + serverUrl: 'https://localhost:1234', + roomId: roomId // Reading a reactive value + }; + if (isEncrypted) { // Reading a reactive value + return createEncryptedConnection(options); + } else { + return createUnencryptedConnection(options); + } + } + + const connection = createConnection(); + connection.on('message', (msg) => onReceiveMessage(msg)); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, isEncrypted]); // ✅ All dependencies declared +``` + +As a result, the chat re-connects only when something meaningful (`roomId` or `isEncrypted`) changes: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +import { showNotification } from "./notifications.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + const [roomId, setRoomId] = useState("general"); + const [isEncrypted, setIsEncrypted] = useState(false); + + return ( + <> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <label> + <input + type="checkbox" + checked={isEncrypted} + onChange={(e) => setIsEncrypted(e.target.checked)} + /> + Enable encryption + </label> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom + roomId={roomId} + isEncrypted={isEncrypted} + onMessage={(msg) => { + showNotification( + "New message: " + msg, + isDark ? "dark" : "light" + ); + }} + /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from "./chat.js"; + +export default function ChatRoom({ roomId, isEncrypted, onMessage }) { + const onReceiveMessage = useEffectEvent(onMessage); + + useEffect(() => { + function createConnection() { + const options = { + serverUrl: "https://localhost:1234", + roomId: roomId, + }; + if (isEncrypted) { + return createEncryptedConnection(options); + } else { + return createUnencryptedConnection(options); + } + } + + const connection = createConnection(); + connection.on("message", (msg) => onReceiveMessage(msg)); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, isEncrypted]); + + return <h1>Welcome to the {roomId} room!</h1>; +} +``` + +```js +export function createEncryptedConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ 🔐 Connecting to "' + roomId + '" room... (encrypted)' + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)' + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} + +export function createUnencryptedConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room (unencrypted)...' + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ Disconnected from "' + roomId + '" room (unencrypted)' + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label, +button { + display: block; + margin-bottom: 5px; +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/render-and-commit.md b/docs/src/learn/render-and-commit.md new file mode 100644 index 000000000..a4bedb8e4 --- /dev/null +++ b/docs/src/learn/render-and-commit.md @@ -0,0 +1,197 @@ +## Overview + +<p class="intro" markdown> + +Before your components are displayed on screen, they must be rendered by React. Understanding the steps in this process will help you think about how your code executes and explain its behavior. + +</p> + +!!! summary "You will learn" + + - What rendering means in React + - When and why React renders a component + - The steps involved in displaying a component on screen + - Why rendering does not always produce a DOM update + +Imagine that your components are cooks in the kitchen, assembling tasty dishes from ingredients. In this scenario, React is the waiter who puts in requests from customers and brings them their orders. This process of requesting and serving UI has three steps: + +1. **Triggering** a render (delivering the guest's order to the kitchen) +2. **Rendering** the component (preparing the order in the kitchen) +3. **Committing** to the DOM (placing the order on the table) + +<IllustrationBlock sequential> + <Illustration caption="Trigger" alt="React as a server in a restaurant, fetching orders from the users and delivering them to the Component Kitchen." src="/images/docs/illustrations/i_render-and-commit1.png" /> + <Illustration caption="Render" alt="The Card Chef gives React a fresh Card component." src="/images/docs/illustrations/i_render-and-commit2.png" /> + <Illustration caption="Commit" alt="React delivers the Card to the user at their table." src="/images/docs/illustrations/i_render-and-commit3.png" /> +</IllustrationBlock> + +## Step 1: Trigger a render + +There are two reasons for a component to render: + +1. It's the component's **initial render.** +2. The component's (or one of its ancestors') **state has been updated.** + +### Initial render + +When your app starts, you need to trigger the initial render. Frameworks and sandboxes sometimes hide this code, but it's done by calling [`createRoot`](/reference/react-dom/client/createRoot) with the target DOM node, and then calling its `render` method with your component: + +```js +import Image from "./Image.js"; +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.getElementById("root")); +root.render(<Image />); +``` + +```js +export default function Image() { + return ( + <img + src="https://i.imgur.com/ZF6s192.jpg" + alt="'Floralis Genérica' by Eduardo Catalano: a gigantic metallic flower sculpture with reflective petals" + /> + ); +} +``` + +Try commenting out the `root.render()` call and see the component disappear! + +### Re-renders when state updates + +Once the component has been initially rendered, you can trigger further renders by updating its state with the [`set` function.](/reference/react/useState#setstate) Updating your component's state automatically queues a render. (You can imagine these as a restaurant guest ordering tea, dessert, and all sorts of things after putting in their first order, depending on the state of their thirst or hunger.) + +<IllustrationBlock sequential> + <Illustration caption="State update..." alt="React as a server in a restaurant, serving a Card UI to the user, represented as a patron with a cursor for their head. They patron expresses they want a pink card, not a black one!" src="/images/docs/illustrations/i_rerender1.png" /> + <Illustration caption="...triggers..." alt="React returns to the Component Kitchen and tells the Card Chef they need a pink Card." src="/images/docs/illustrations/i_rerender2.png" /> + <Illustration caption="...render!" alt="The Card Chef gives React the pink Card." src="/images/docs/illustrations/i_rerender3.png" /> +</IllustrationBlock> + +## Step 2: React renders your components + +After you trigger a render, React calls your components to figure out what to display on screen. **"Rendering" is React calling your components.** + +- **On initial render,** React will call the root component. +- **For subsequent renders,** React will call the function component whose state update triggered the render. + +This process is recursive: if the updated component returns some other component, React will render _that_ component next, and if that component also returns something, it will render _that_ component next, and so on. The process will continue until there are no more nested components and React knows exactly what should be displayed on screen. + +In the following example, React will call `Gallery()` and `Image()` several times: + +```js +export default function Gallery() { + return ( + <section> + <h1>Inspiring Sculptures</h1> + <Image /> + <Image /> + <Image /> + </section> + ); +} + +function Image() { + return ( + <img + src="https://i.imgur.com/ZF6s192.jpg" + alt="'Floralis Genérica' by Eduardo Catalano: a gigantic metallic flower sculpture with reflective petals" + /> + ); +} +``` + +```js +import Gallery from "./Gallery.js"; +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.getElementById("root")); +root.render(<Gallery />); +``` + +```css +img { + margin: 0 10px 10px 0; +} +``` + +- **During the initial render,** React will [create the DOM nodes](https://developer.mozilla.org/docs/Web/API/Document/createElement) for `<section>`, `<h1>`, and three `<img>` tags. +- **During a re-render,** React will calculate which of their properties, if any, have changed since the previous render. It won't do anything with that information until the next step, the commit phase. + +<Pitfall> + +Rendering must always be a [pure calculation](/learn/keeping-components-pure): + +- **Same inputs, same output.** Given the same inputs, a component should always return the same JSX. (When someone orders a salad with tomatoes, they should not receive a salad with onions!) +- **It minds its own business.** It should not change any objects or variables that existed before rendering. (One order should not change anyone else's order.) + +Otherwise, you can encounter confusing bugs and unpredictable behavior as your codebase grows in complexity. When developing in "Strict Mode", React calls each component's function twice, which can help surface mistakes caused by impure functions. + +</Pitfall> + +<DeepDive> + +#### Optimizing performance + +The default behavior of rendering all components nested within the updated component is not optimal for performance if the updated component is very high in the tree. If you run into a performance issue, there are several opt-in ways to solve it described in the [Performance](https://reactjs.org/docs/optimizing-performance.html) section. **Don't optimize prematurely!** + +</DeepDive> + +## Step 3: React commits changes to the DOM + +After rendering (calling) your components, React will modify the DOM. + +- **For the initial render,** React will use the [`appendChild()`](https://developer.mozilla.org/docs/Web/API/Node/appendChild) DOM API to put all the DOM nodes it has created on screen. +- **For re-renders,** React will apply the minimal necessary operations (calculated while rendering!) to make the DOM match the latest rendering output. + +**React only changes the DOM nodes if there's a difference between renders.** For example, here is a component that re-renders with different props passed from its parent every second. Notice how you can add some text into the `<input>`, updating its `value`, but the text doesn't disappear when the component re-renders: + +```js +export default function Clock({ time }) { + return ( + <> + <h1>{time}</h1> + <input /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import Clock from "./Clock.js"; + +function useTime() { + const [time, setTime] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(id); + }, []); + return time; +} + +export default function App() { + const time = useTime(); + return <Clock time={time.toLocaleTimeString()} />; +} +``` + +This works because during this last step, React only updates the content of `<h1>` with the new `time`. It sees that the `<input>` appears in the JSX in the same place as last time, so React doesn't touch the `<input>`—or its `value`! + +## Epilogue: Browser paint + +After rendering is done and React updated the DOM, the browser will repaint the screen. Although this process is known as "browser rendering", we'll refer to it as "painting" to avoid confusion throughout the docs. + +<Illustration alt="A browser painting 'still life with card element'." src="/images/docs/illustrations/i_browser-paint.png" /> + +<Recap> + +- Any screen update in a React app happens in three steps: + 1. Trigger + 2. Render + 3. Commit +- You can use Strict Mode to find mistakes in your components +- React does not touch the DOM if the rendering result is the same as last time + +</Recap> diff --git a/docs/src/learn/rendering-lists.md b/docs/src/learn/rendering-lists.md new file mode 100644 index 000000000..115c55fe3 --- /dev/null +++ b/docs/src/learn/rendering-lists.md @@ -0,0 +1,1268 @@ +## Overview + +<p class="intro" markdown> + +You will often want to display multiple similar components from a collection of data. You can use the [JavaScript array methods](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array#) to manipulate an array of data. On this page, you'll use [`filter()`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) and [`map()`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/map) with React to filter and transform your array of data into an array of components. + +</p> + +!!! summary "You will learn" + + - How to render components from an array using JavaScript's `map()` + - How to render only specific components using JavaScript's `filter()` + - When and why to use React keys + +## Rendering data from arrays + +Say that you have a list of content. + +```js +<ul> + <li>Creola Katherine Johnson: mathematician</li> + <li>Mario José Molina-Pasquel Henríquez: chemist</li> + <li>Mohammad Abdus Salam: physicist</li> + <li>Percy Lavon Julian: chemist</li> + <li>Subrahmanyan Chandrasekhar: astrophysicist</li> +</ul> +``` + +The only difference among those list items is their contents, their data. You will often need to show several instances of the same component using different data when building interfaces: from lists of comments to galleries of profile images. In these situations, you can store that data in JavaScript objects and arrays and use methods like [`map()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) and [`filter()`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) to render lists of components from them. + +Here’s a short example of how to generate a list of items from an array: + +1. **Move** the data into an array: + +```js +const people = [ + "Creola Katherine Johnson: mathematician", + "Mario José Molina-Pasquel Henríquez: chemist", + "Mohammad Abdus Salam: physicist", + "Percy Lavon Julian: chemist", + "Subrahmanyan Chandrasekhar: astrophysicist", +]; +``` + +2. **Map** the `people` members into a new array of JSX nodes, `listItems`: + +```js +const listItems = people.map((person) => <li>{person}</li>); +``` + +3. **Return** `listItems` from your component wrapped in a `<ul>`: + +```js +return <ul>{listItems}</ul>; +``` + +Here is the result: + +```js +const people = [ + "Creola Katherine Johnson: mathematician", + "Mario José Molina-Pasquel Henríquez: chemist", + "Mohammad Abdus Salam: physicist", + "Percy Lavon Julian: chemist", + "Subrahmanyan Chandrasekhar: astrophysicist", +]; + +export default function List() { + const listItems = people.map((person) => <li>{person}</li>); + return <ul>{listItems}</ul>; +} +``` + +```css +li { + margin-bottom: 10px; +} +``` + +Notice the sandbox above displays a console error: + +<ConsoleBlock level="error"> + +Warning: Each child in a list should have a unique "key" prop. + +</ConsoleBlock> + +You'll learn how to fix this error later on this page. Before we get to that, let's add some structure to your data. + +## Filtering arrays of items + +This data can be structured even more. + +```js +const people = [ + { + id: 0, + name: "Creola Katherine Johnson", + profession: "mathematician", + }, + { + id: 1, + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + }, + { + id: 2, + name: "Mohammad Abdus Salam", + profession: "physicist", + }, + { + name: "Percy Lavon Julian", + profession: "chemist", + }, + { + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + }, +]; +``` + +Let's say you want a way to only show people whose profession is `'chemist'`. You can use JavaScript's `filter()` method to return just those people. This method takes an array of items, passes them through a “test” (a function that returns `true` or `false`), and returns a new array of only those items that passed the test (returned `true`). + +You only want the items where `profession` is `'chemist'`. The "test" function for this looks like `(person) => person.profession === 'chemist'`. Here's how to put it together: + +1. **Create** a new array of just “chemist” people, `chemists`, by calling `filter()` on the `people` filtering by `person.profession === 'chemist'`: + +```js +const chemists = people.filter((person) => person.profession === "chemist"); +``` + +2. Now **map** over `chemists`: + +```js +const listItems = chemists.map((person) => ( + <li> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> +)); +``` + +3. Lastly, **return** the `listItems` from your component: + +```js +return <ul>{listItems}</ul>; +``` + +```js +import { people } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +export default function List() { + const chemists = people.filter((person) => person.profession === "chemist"); + const listItems = chemists.map((person) => ( + <li> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + )); + return <ul>{listItems}</ul>; +} +``` + +```js +export const people = [ + { + id: 0, + name: "Creola Katherine Johnson", + profession: "mathematician", + accomplishment: "spaceflight calculations", + imageId: "MK3eW3A", + }, + { + id: 1, + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + accomplishment: "discovery of Arctic ozone hole", + imageId: "mynHUSa", + }, + { + id: 2, + name: "Mohammad Abdus Salam", + profession: "physicist", + accomplishment: "electromagnetism theory", + imageId: "bE7W1ji", + }, + { + id: 3, + name: "Percy Lavon Julian", + profession: "chemist", + accomplishment: + "pioneering cortisone drugs, steroids and birth control pills", + imageId: "IOjWm71", + }, + { + id: 4, + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + accomplishment: "white dwarf star mass calculations", + imageId: "lrWQx8l", + }, +]; +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + "s.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +img { + width: 100px; + height: 100px; + border-radius: 50%; +} +``` + +<Pitfall> + +Arrow functions implicitly return the expression right after `=>`, so you didn't need a `return` statement: + +```js +const listItems = chemists.map( + (person) => <li>...</li> // Implicit return! +); +``` + +However, **you must write `return` explicitly if your `=>` is followed by a `{` curly brace!** + +```js +const listItems = chemists.map((person) => { + // Curly brace + return <li>...</li>; +}); +``` + +Arrow functions containing `=> {` are said to have a ["block body".](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions#function_body) They let you write more than a single line of code, but you _have to_ write a `return` statement yourself. If you forget it, nothing gets returned! + +</Pitfall> + +## Keeping list items in order with `key` + +Notice that all the sandboxes above show an error in the console: + +<ConsoleBlock level="error"> + +Warning: Each child in a list should have a unique "key" prop. + +</ConsoleBlock> + +You need to give each array item a `key` -- a string or a number that uniquely identifies it among other items in that array: + +```js +<li key={person.id}>...</li> +``` + +<Note> + +JSX elements directly inside a `map()` call always need keys! + +</Note> + +Keys tell React which array item each component corresponds to, so that it can match them up later. This becomes important if your array items can move (e.g. due to sorting), get inserted, or get deleted. A well-chosen `key` helps React infer what exactly has happened, and make the correct updates to the DOM tree. + +Rather than generating keys on the fly, you should include them in your data: + +```js +import { people } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +export default function List() { + const listItems = people.map((person) => ( + <li key={person.id}> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + )); + return <ul>{listItems}</ul>; +} +``` + +```js +export const people = [ + { + id: 0, // Used in JSX as a key + name: "Creola Katherine Johnson", + profession: "mathematician", + accomplishment: "spaceflight calculations", + imageId: "MK3eW3A", + }, + { + id: 1, // Used in JSX as a key + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + accomplishment: "discovery of Arctic ozone hole", + imageId: "mynHUSa", + }, + { + id: 2, // Used in JSX as a key + name: "Mohammad Abdus Salam", + profession: "physicist", + accomplishment: "electromagnetism theory", + imageId: "bE7W1ji", + }, + { + id: 3, // Used in JSX as a key + name: "Percy Lavon Julian", + profession: "chemist", + accomplishment: + "pioneering cortisone drugs, steroids and birth control pills", + imageId: "IOjWm71", + }, + { + id: 4, // Used in JSX as a key + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + accomplishment: "white dwarf star mass calculations", + imageId: "lrWQx8l", + }, +]; +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + "s.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +img { + width: 100px; + height: 100px; + border-radius: 50%; +} +``` + +<DeepDive> + +#### Displaying several DOM nodes for each list item + +What do you do when each item needs to render not one, but several DOM nodes? + +The short [`<>...</>` Fragment](/reference/react/Fragment) syntax won't let you pass a key, so you need to either group them into a single `<div>`, or use the slightly longer and [more explicit `<Fragment>` syntax:](/reference/react/Fragment#rendering-a-list-of-fragments) + +```js +import { Fragment } from "react"; + +// ... + +const listItems = people.map((person) => ( + <Fragment key={person.id}> + <h1>{person.name}</h1> + <p>{person.bio}</p> + </Fragment> +)); +``` + +Fragments disappear from the DOM, so this will produce a flat list of `<h1>`, `<p>`, `<h1>`, `<p>`, and so on. + +</DeepDive> + +### Where to get your `key` + +Different sources of data provide different sources of keys: + +- **Data from a database:** If your data is coming from a database, you can use the database keys/IDs, which are unique by nature. +- **Locally generated data:** If your data is generated and persisted locally (e.g. notes in a note-taking app), use an incrementing counter, [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) or a package like [`uuid`](https://www.npmjs.com/package/uuid) when creating items. + +### Rules of keys + +- **Keys must be unique among siblings.** However, it’s okay to use the same keys for JSX nodes in _different_ arrays. +- **Keys must not change** or that defeats their purpose! Don't generate them while rendering. + +### Why does React need keys? + +Imagine that files on your desktop didn't have names. Instead, you'd refer to them by their order -- the first file, the second file, and so on. You could get used to it, but once you delete a file, it would get confusing. The second file would become the first file, the third file would be the second file, and so on. + +File names in a folder and JSX keys in an array serve a similar purpose. They let us uniquely identify an item between its siblings. A well-chosen key provides more information than the position within the array. Even if the _position_ changes due to reordering, the `key` lets React identify the item throughout its lifetime. + +<Pitfall> + +You might be tempted to use an item's index in the array as its key. In fact, that's what React will use if you don't specify a `key` at all. But the order in which you render items will change over time if an item is inserted, deleted, or if the array gets reordered. Index as a key often leads to subtle and confusing bugs. + +Similarly, do not generate keys on the fly, e.g. with `key={Math.random()}`. This will cause keys to never match up between renders, leading to all your components and DOM being recreated every time. Not only is this slow, but it will also lose any user input inside the list items. Instead, use a stable ID based on the data. + +Note that your components won't receive `key` as a prop. It's only used as a hint by React itself. If your component needs an ID, you have to pass it as a separate prop: `<Profile key={id} userId={id} />`. + +</Pitfall> + +<Recap> + +On this page you learned: + +- How to move data out of components and into data structures like arrays and objects. +- How to generate sets of similar components with JavaScript's `map()`. +- How to create arrays of filtered items with JavaScript's `filter()`. +- Why and how to set `key` on each component in a collection so React can keep track of each of them even if their position or data changes. + +</Recap> + +<Challenges> + +#### Splitting a list in two + +This example shows a list of all people. + +Change it to show two separate lists one after another: **Chemists** and **Everyone Else.** Like previously, you can determine whether a person is a chemist by checking if `person.profession === 'chemist'`. + +```js +import { people } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +export default function List() { + const listItems = people.map((person) => ( + <li key={person.id}> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + )); + return ( + <article> + <h1>Scientists</h1> + <ul>{listItems}</ul> + </article> + ); +} +``` + +```js +export const people = [ + { + id: 0, + name: "Creola Katherine Johnson", + profession: "mathematician", + accomplishment: "spaceflight calculations", + imageId: "MK3eW3A", + }, + { + id: 1, + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + accomplishment: "discovery of Arctic ozone hole", + imageId: "mynHUSa", + }, + { + id: 2, + name: "Mohammad Abdus Salam", + profession: "physicist", + accomplishment: "electromagnetism theory", + imageId: "bE7W1ji", + }, + { + id: 3, + name: "Percy Lavon Julian", + profession: "chemist", + accomplishment: + "pioneering cortisone drugs, steroids and birth control pills", + imageId: "IOjWm71", + }, + { + id: 4, + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + accomplishment: "white dwarf star mass calculations", + imageId: "lrWQx8l", + }, +]; +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + "s.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +img { + width: 100px; + height: 100px; + border-radius: 50%; +} +``` + +<Solution> + +You could use `filter()` twice, creating two separate arrays, and then `map` over both of them: + +```js +import { people } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +export default function List() { + const chemists = people.filter((person) => person.profession === "chemist"); + const everyoneElse = people.filter( + (person) => person.profession !== "chemist" + ); + return ( + <article> + <h1>Scientists</h1> + <h2>Chemists</h2> + <ul> + {chemists.map((person) => ( + <li key={person.id}> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + ))} + </ul> + <h2>Everyone Else</h2> + <ul> + {everyoneElse.map((person) => ( + <li key={person.id}> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + ))} + </ul> + </article> + ); +} +``` + +```js +export const people = [ + { + id: 0, + name: "Creola Katherine Johnson", + profession: "mathematician", + accomplishment: "spaceflight calculations", + imageId: "MK3eW3A", + }, + { + id: 1, + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + accomplishment: "discovery of Arctic ozone hole", + imageId: "mynHUSa", + }, + { + id: 2, + name: "Mohammad Abdus Salam", + profession: "physicist", + accomplishment: "electromagnetism theory", + imageId: "bE7W1ji", + }, + { + id: 3, + name: "Percy Lavon Julian", + profession: "chemist", + accomplishment: + "pioneering cortisone drugs, steroids and birth control pills", + imageId: "IOjWm71", + }, + { + id: 4, + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + accomplishment: "white dwarf star mass calculations", + imageId: "lrWQx8l", + }, +]; +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + "s.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +img { + width: 100px; + height: 100px; + border-radius: 50%; +} +``` + +In this solution, the `map` calls are placed directly inline into the parent `<ul>` elements, but you could introduce variables for them if you find that more readable. + +There is still a bit duplication between the rendered lists. You can go further and extract the repetitive parts into a `<ListSection>` component: + +```js +import { people } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +function ListSection({ title, people }) { + return ( + <> + <h2>{title}</h2> + <ul> + {people.map((person) => ( + <li key={person.id}> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + ))} + </ul> + </> + ); +} + +export default function List() { + const chemists = people.filter((person) => person.profession === "chemist"); + const everyoneElse = people.filter( + (person) => person.profession !== "chemist" + ); + return ( + <article> + <h1>Scientists</h1> + <ListSection title="Chemists" people={chemists} /> + <ListSection title="Everyone Else" people={everyoneElse} /> + </article> + ); +} +``` + +```js +export const people = [ + { + id: 0, + name: "Creola Katherine Johnson", + profession: "mathematician", + accomplishment: "spaceflight calculations", + imageId: "MK3eW3A", + }, + { + id: 1, + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + accomplishment: "discovery of Arctic ozone hole", + imageId: "mynHUSa", + }, + { + id: 2, + name: "Mohammad Abdus Salam", + profession: "physicist", + accomplishment: "electromagnetism theory", + imageId: "bE7W1ji", + }, + { + id: 3, + name: "Percy Lavon Julian", + profession: "chemist", + accomplishment: + "pioneering cortisone drugs, steroids and birth control pills", + imageId: "IOjWm71", + }, + { + id: 4, + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + accomplishment: "white dwarf star mass calculations", + imageId: "lrWQx8l", + }, +]; +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + "s.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +img { + width: 100px; + height: 100px; + border-radius: 50%; +} +``` + +A very attentive reader might notice that with two `filter` calls, we check each person's profession twice. Checking a property is very fast, so in this example it's fine. If your logic was more expensive than that, you could replace the `filter` calls with a loop that manually constructs the arrays and checks each person once. + +In fact, if `people` never change, you could move this code out of your component. From React's perspective, all that matters is that you give it an array of JSX nodes in the end. It doesn't care how you produce that array: + +```js +import { people } from "./data.js"; +import { getImageUrl } from "./utils.js"; + +let chemists = []; +let everyoneElse = []; +people.forEach((person) => { + if (person.profession === "chemist") { + chemists.push(person); + } else { + everyoneElse.push(person); + } +}); + +function ListSection({ title, people }) { + return ( + <> + <h2>{title}</h2> + <ul> + {people.map((person) => ( + <li key={person.id}> + <img src={getImageUrl(person)} alt={person.name} /> + <p> + <b>{person.name}:</b> + {" " + person.profession + " "} + known for {person.accomplishment} + </p> + </li> + ))} + </ul> + </> + ); +} + +export default function List() { + return ( + <article> + <h1>Scientists</h1> + <ListSection title="Chemists" people={chemists} /> + <ListSection title="Everyone Else" people={everyoneElse} /> + </article> + ); +} +``` + +```js +export const people = [ + { + id: 0, + name: "Creola Katherine Johnson", + profession: "mathematician", + accomplishment: "spaceflight calculations", + imageId: "MK3eW3A", + }, + { + id: 1, + name: "Mario José Molina-Pasquel Henríquez", + profession: "chemist", + accomplishment: "discovery of Arctic ozone hole", + imageId: "mynHUSa", + }, + { + id: 2, + name: "Mohammad Abdus Salam", + profession: "physicist", + accomplishment: "electromagnetism theory", + imageId: "bE7W1ji", + }, + { + id: 3, + name: "Percy Lavon Julian", + profession: "chemist", + accomplishment: + "pioneering cortisone drugs, steroids and birth control pills", + imageId: "IOjWm71", + }, + { + id: 4, + name: "Subrahmanyan Chandrasekhar", + profession: "astrophysicist", + accomplishment: "white dwarf star mass calculations", + imageId: "lrWQx8l", + }, +]; +``` + +```js +export function getImageUrl(person) { + return "https://i.imgur.com/" + person.imageId + "s.jpg"; +} +``` + +```css +ul { + list-style-type: none; + padding: 0px 10px; +} +li { + margin-bottom: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 20px; + align-items: center; +} +img { + width: 100px; + height: 100px; + border-radius: 50%; +} +``` + +</Solution> + +#### Nested lists in one component + +Make a list of recipes from this array! For each recipe in the array, display its name as an `<h2>` and list its ingredients in a `<ul>`. + +<Hint> + +This will require nesting two different `map` calls. + +</Hint> + +```js +import { recipes } from "./data.js"; + +export default function RecipeList() { + return ( + <div> + <h1>Recipes</h1> + </div> + ); +} +``` + +```js +export const recipes = [ + { + id: "greek-salad", + name: "Greek Salad", + ingredients: ["tomatoes", "cucumber", "onion", "olives", "feta"], + }, + { + id: "hawaiian-pizza", + name: "Hawaiian Pizza", + ingredients: [ + "pizza crust", + "pizza sauce", + "mozzarella", + "ham", + "pineapple", + ], + }, + { + id: "hummus", + name: "Hummus", + ingredients: [ + "chickpeas", + "olive oil", + "garlic cloves", + "lemon", + "tahini", + ], + }, +]; +``` + +<Solution> + +Here is one way you could go about it: + +```js +import { recipes } from "./data.js"; + +export default function RecipeList() { + return ( + <div> + <h1>Recipes</h1> + {recipes.map((recipe) => ( + <div key={recipe.id}> + <h2>{recipe.name}</h2> + <ul> + {recipe.ingredients.map((ingredient) => ( + <li key={ingredient}>{ingredient}</li> + ))} + </ul> + </div> + ))} + </div> + ); +} +``` + +```js +export const recipes = [ + { + id: "greek-salad", + name: "Greek Salad", + ingredients: ["tomatoes", "cucumber", "onion", "olives", "feta"], + }, + { + id: "hawaiian-pizza", + name: "Hawaiian Pizza", + ingredients: [ + "pizza crust", + "pizza sauce", + "mozzarella", + "ham", + "pineapple", + ], + }, + { + id: "hummus", + name: "Hummus", + ingredients: [ + "chickpeas", + "olive oil", + "garlic cloves", + "lemon", + "tahini", + ], + }, +]; +``` + +Each of the `recipes` already includes an `id` field, so that's what the outer loop uses for its `key`. There is no ID you could use to loop over ingredients. However, it's reasonable to assume that the same ingredient won't be listed twice within the same recipe, so its name can serve as a `key`. Alternatively, you could change the data structure to add IDs, or use index as a `key` (with the caveat that you can't safely reorder ingredients). + +</Solution> + +#### Extracting a list item component + +This `RecipeList` component contains two nested `map` calls. To simplify it, extract a `Recipe` component from it which will accept `id`, `name`, and `ingredients` props. Where do you place the outer `key` and why? + +```js +import { recipes } from "./data.js"; + +export default function RecipeList() { + return ( + <div> + <h1>Recipes</h1> + {recipes.map((recipe) => ( + <div key={recipe.id}> + <h2>{recipe.name}</h2> + <ul> + {recipe.ingredients.map((ingredient) => ( + <li key={ingredient}>{ingredient}</li> + ))} + </ul> + </div> + ))} + </div> + ); +} +``` + +```js +export const recipes = [ + { + id: "greek-salad", + name: "Greek Salad", + ingredients: ["tomatoes", "cucumber", "onion", "olives", "feta"], + }, + { + id: "hawaiian-pizza", + name: "Hawaiian Pizza", + ingredients: [ + "pizza crust", + "pizza sauce", + "mozzarella", + "ham", + "pineapple", + ], + }, + { + id: "hummus", + name: "Hummus", + ingredients: [ + "chickpeas", + "olive oil", + "garlic cloves", + "lemon", + "tahini", + ], + }, +]; +``` + +<Solution> + +You can copy-paste the JSX from the outer `map` into a new `Recipe` component and return that JSX. Then you can change `recipe.name` to `name`, `recipe.id` to `id`, and so on, and pass them as props to the `Recipe`: + +```js +import { recipes } from "./data.js"; + +function Recipe({ id, name, ingredients }) { + return ( + <div> + <h2>{name}</h2> + <ul> + {ingredients.map((ingredient) => ( + <li key={ingredient}>{ingredient}</li> + ))} + </ul> + </div> + ); +} + +export default function RecipeList() { + return ( + <div> + <h1>Recipes</h1> + {recipes.map((recipe) => ( + <Recipe {...recipe} key={recipe.id} /> + ))} + </div> + ); +} +``` + +```js +export const recipes = [ + { + id: "greek-salad", + name: "Greek Salad", + ingredients: ["tomatoes", "cucumber", "onion", "olives", "feta"], + }, + { + id: "hawaiian-pizza", + name: "Hawaiian Pizza", + ingredients: [ + "pizza crust", + "pizza sauce", + "mozzarella", + "ham", + "pineapple", + ], + }, + { + id: "hummus", + name: "Hummus", + ingredients: [ + "chickpeas", + "olive oil", + "garlic cloves", + "lemon", + "tahini", + ], + }, +]; +``` + +Here, `<Recipe {...recipe} key={recipe.id} />` is a syntax shortcut saying "pass all properties of the `recipe` object as props to the `Recipe` component". You could also write each prop explicitly: `<Recipe id={recipe.id} name={recipe.name} ingredients={recipe.ingredients} key={recipe.id} />`. + +**Note that the `key` is specified on the `<Recipe>` itself rather than on the root `<div>` returned from `Recipe`.** This is because this `key` is needed directly within the context of the surrounding array. Previously, you had an array of `<div>`s so each of them needed a `key`, but now you have an array of `<Recipe>`s. In other words, when you extract a component, don't forget to leave the `key` outside the JSX you copy and paste. + +</Solution> + +#### List with a separator + +This example renders a famous haiku by Katsushika Hokusai, with each line wrapped in a `<p>` tag. Your job is to insert an `<hr />` separator between each paragraph. Your resulting structure should look like this: + +```js +<article> + <p>I write, erase, rewrite</p> + <hr /> + <p>Erase again, and then</p> + <hr /> + <p>A poppy blooms.</p> +</article> +``` + +A haiku only contains three lines, but your solution should work with any number of lines. Note that `<hr />` elements only appear _between_ the `<p>` elements, not in the beginning or the end! + +```js +const poem = { + lines: [ + "I write, erase, rewrite", + "Erase again, and then", + "A poppy blooms.", + ], +}; + +export default function Poem() { + return ( + <article> + {poem.lines.map((line, index) => ( + <p key={index}>{line}</p> + ))} + </article> + ); +} +``` + +```css +body { + text-align: center; +} +p { + font-family: Georgia, serif; + font-size: 20px; + font-style: italic; +} +hr { + margin: 0 120px 0 120px; + border: 1px dashed #45c3d8; +} +``` + +(This is a rare case where index as a key is acceptable because a poem's lines will never reorder.) + +<Hint> + +You'll either need to convert `map` to a manual loop, or use a fragment. + +</Hint> + +<Solution> + +You can write a manual loop, inserting `<hr />` and `<p>...</p>` into the output array as you go: + +```js +const poem = { + lines: [ + "I write, erase, rewrite", + "Erase again, and then", + "A poppy blooms.", + ], +}; + +export default function Poem() { + let output = []; + + // Fill the output array + poem.lines.forEach((line, i) => { + output.push(<hr key={i + "-separator"} />); + output.push(<p key={i + "-text"}>{line}</p>); + }); + // Remove the first <hr /> + output.shift(); + + return <article>{output}</article>; +} +``` + +```css +body { + text-align: center; +} +p { + font-family: Georgia, serif; + font-size: 20px; + font-style: italic; +} +hr { + margin: 0 120px 0 120px; + border: 1px dashed #45c3d8; +} +``` + +Using the original line index as a `key` doesn't work anymore because each separator and paragraph are now in the same array. However, you can give each of them a distinct key using a suffix, e.g. `key={i + '-text'}`. + +Alternatively, you could render a collection of fragments which contain `<hr />` and `<p>...</p>`. However, the `<>...</>` shorthand syntax doesn't support passing keys, so you'd have to write `<Fragment>` explicitly: + +```js +import { Fragment } from "react"; + +const poem = { + lines: [ + "I write, erase, rewrite", + "Erase again, and then", + "A poppy blooms.", + ], +}; + +export default function Poem() { + return ( + <article> + {poem.lines.map((line, i) => ( + <Fragment key={i}> + {i > 0 && <hr />} + <p>{line}</p> + </Fragment> + ))} + </article> + ); +} +``` + +```css +body { + text-align: center; +} +p { + font-family: Georgia, serif; + font-size: 20px; + font-style: italic; +} +hr { + margin: 0 120px 0 120px; + border: 1px dashed #45c3d8; +} +``` + +Remember, fragments (often written as `<> </>`) let you group JSX nodes without adding extra `<div>`s! + +</Solution> + +</Challenges> diff --git a/docs/src/learn/responding-to-events.md b/docs/src/learn/responding-to-events.md new file mode 100644 index 000000000..6c25fab56 --- /dev/null +++ b/docs/src/learn/responding-to-events.md @@ -0,0 +1,652 @@ +## Overview + +<p class="intro" markdown> + +React lets you add _event handlers_ to your PSX. Event handlers are your own functions that will be triggered in response to interactions like clicking, hovering, focusing form inputs, and so on. + +</p> + +!!! summary "You will learn" + + - Different ways to write an event handler + - How to pass event handling logic from a parent component + - How events propagate and how to stop them + +## Adding event handlers + +To add an event handler, you will first define a function and then [pass it as a prop](../learn/passing-props-to-a-component.md) to the appropriate PSX tag. For example, here is a button that doesn't do anything yet: + +=== "app.py" + + ```python + {% include "../../examples/responding_to_events/simple_button.py" start="# start" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +You can make it show a message when a user clicks by following these three steps: + +1. Declare a function called `handle_click` _inside_ your `#!python def button():` component. +2. Implement the logic inside that function (use `print` to show the message). +3. Add `on_click=handle_click` to the `html.button` PSX. + +=== "app.py" + + ```python + {% include "../../examples/responding_to_events/simple_button_event.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/responding_to_events/simple_button_event.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +You defined the `handleClick` function and then [passed it as a prop](/learn/passing-props-to-a-component) to `<button>`. `handleClick` is an **event handler.** Event handler functions: + +- Are usually defined _inside_ your components. +- Have names that start with `handle`, followed by the name of the event. + +By convention, it is common to name event handlers as `handle` followed by the event name. You'll often see `on_click={handleClick}`, `onMouseEnter={handleMouseEnter}`, and so on. + +Alternatively, you can define an event handler inline in the JSX: + +```jsx +<button on_click={function handleClick() { + alert('You clicked me!'); +}}> +``` + +Or, more concisely, using an arrow function: + +```jsx +<button on_click={() => { + alert('You clicked me!'); +}}> +``` + +All of these styles are equivalent. Inline event handlers are convenient for short functions. + +<Pitfall> + +Functions passed to event handlers must be passed, not called. For example: + +| passing a function (correct) | calling a function (incorrect) | +| --------------------------------- | ----------------------------------- | +| `<button on_click={handleClick}>` | `<button on_click={handleClick()}>` | + +The difference is subtle. In the first example, the `handleClick` function is passed as an `on_click` event handler. This tells React to remember it and only call your function when the user clicks the button. + +In the second example, the `()` at the end of `handleClick()` fires the function _immediately_ during [rendering](/learn/render-and-commit), without any clicks. This is because JavaScript inside the [JSX `{` and `}`](/learn/javascript-in-jsx-with-curly-braces) executes right away. + +When you write code inline, the same pitfall presents itself in a different way: + +| passing a function (correct) | calling a function (incorrect) | +| ---------------------------------------- | ---------------------------------- | +| `<button on_click={() => alert('...')}>` | `<button on_click={alert('...')}>` | + +Passing inline code like this won't fire on click—it fires every time the component renders: + +```jsx +// This alert fires when the component renders, not when clicked! +<button on_click={alert('You clicked me!')}> +``` + +If you want to define your event handler inline, wrap it in an anonymous function like so: + +```jsx +<button on_click={() => alert('You clicked me!')}> +``` + +Rather than executing the code inside with every render, this creates a function to be called later. + +In both cases, what you want to pass is a function: + +- `<button on_click={handleClick}>` passes the `handleClick` function. +- `<button on_click={() => alert('...')}>` passes the `() => alert('...')` function. + +[Read more about arrow functions.](https://javascript.info/arrow-functions-basics) + +</Pitfall> + +### Reading props in event handlers + +Because event handlers are declared inside of a component, they have access to the component's props. Here is a button that, when clicked, shows an alert with its `message` prop: + +```js +function AlertButton({ message, children }) { + return <button on_click={() => alert(message)}>{children}</button>; +} + +export default function Toolbar() { + return ( + <div> + <AlertButton message="Playing!">Play Movie</AlertButton> + <AlertButton message="Uploading!">Upload Image</AlertButton> + </div> + ); +} +``` + +```css +button { + margin-right: 10px; +} +``` + +This lets these two buttons show different messages. Try changing the messages passed to them. + +### Passing event handlers as props + +Often you'll want the parent component to specify a child's event handler. Consider buttons: depending on where you're using a `Button` component, you might want to execute a different function—perhaps one plays a movie and another uploads an image. + +To do this, pass a prop the component receives from its parent as the event handler like so: + +```js +function Button({ on_click, children }) { + return <button on_click={on_click}>{children}</button>; +} + +function PlayButton({ movieName }) { + function handlePlayClick() { + alert(`Playing ${movieName}!`); + } + + return <Button on_click={handlePlayClick}>Play "{movieName}"</Button>; +} + +function UploadButton() { + return <Button on_click={() => alert("Uploading!")}>Upload Image</Button>; +} + +export default function Toolbar() { + return ( + <div> + <PlayButton movieName="Kiki's Delivery Service" /> + <UploadButton /> + </div> + ); +} +``` + +```css +button { + margin-right: 10px; +} +``` + +Here, the `Toolbar` component renders a `PlayButton` and an `UploadButton`: + +- `PlayButton` passes `handlePlayClick` as the `on_click` prop to the `Button` inside. +- `UploadButton` passes `() => alert('Uploading!')` as the `on_click` prop to the `Button` inside. + +Finally, your `Button` component accepts a prop called `on_click`. It passes that prop directly to the built-in browser `<button>` with `on_click={on_click}`. This tells React to call the passed function on click. + +If you use a [design system](https://uxdesign.cc/everything-you-need-to-know-about-design-systems-54b109851969), it's common for components like buttons to contain styling but not specify behavior. Instead, components like `PlayButton` and `UploadButton` will pass event handlers down. + +### Naming event handler props + +Built-in components like `<button>` and `<div>` only support [browser event names](/reference/react-dom/components/common#common-props) like `on_click`. However, when you're building your own components, you can name their event handler props any way that you like. + +By convention, event handler props should start with `on`, followed by a capital letter. + +For example, the `Button` component's `on_click` prop could have been called `onSmash`: + +```js +function Button({ onSmash, children }) { + return <button on_click={onSmash}>{children}</button>; +} + +export default function App() { + return ( + <div> + <Button onSmash={() => alert("Playing!")}>Play Movie</Button> + <Button onSmash={() => alert("Uploading!")}>Upload Image</Button> + </div> + ); +} +``` + +```css +button { + margin-right: 10px; +} +``` + +In this example, `<button on_click={onSmash}>` shows that the browser `<button>` (lowercase) still needs a prop called `on_click`, but the prop name received by your custom `Button` component is up to you! + +When your component supports multiple interactions, you might name event handler props for app-specific concepts. For example, this `Toolbar` component receives `onPlayMovie` and `onUploadImage` event handlers: + +```js +export default function App() { + return ( + <Toolbar + onPlayMovie={() => alert("Playing!")} + onUploadImage={() => alert("Uploading!")} + /> + ); +} + +function Toolbar({ onPlayMovie, onUploadImage }) { + return ( + <div> + <Button on_click={onPlayMovie}>Play Movie</Button> + <Button on_click={onUploadImage}>Upload Image</Button> + </div> + ); +} + +function Button({ on_click, children }) { + return <button on_click={on_click}>{children}</button>; +} +``` + +```css +button { + margin-right: 10px; +} +``` + +Notice how the `App` component does not need to know _what_ `Toolbar` will do with `onPlayMovie` or `onUploadImage`. That's an implementation detail of the `Toolbar`. Here, `Toolbar` passes them down as `on_click` handlers to its `Button`s, but it could later also trigger them on a keyboard shortcut. Naming props after app-specific interactions like `onPlayMovie` gives you the flexibility to change how they're used later. + +<Note> + +Make sure that you use the appropriate HTML tags for your event handlers. For example, to handle clicks, use [`<button on_click={handleClick}>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button) instead of `<div on_click={handleClick}>`. Using a real browser `<button>` enables built-in browser behaviors like keyboard navigation. If you don't like the default browser styling of a button and want to make it look more like a link or a different UI element, you can achieve it with CSS. [Learn more about writing accessible markup.](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/HTML) + +</Note> + +## Event propagation + +Event handlers will also catch events from any children your component might have. We say that an event "bubbles" or "propagates" up the tree: it starts with where the event happened, and then goes up the tree. + +This `<div>` contains two buttons. Both the `<div>` _and_ each button have their own `on_click` handlers. Which handlers do you think will fire when you click a button? + +```js +export default function Toolbar() { + return ( + <div + className="Toolbar" + on_click={() => { + alert("You clicked on the toolbar!"); + }} + > + <button on_click={() => alert("Playing!")}>Play Movie</button> + <button on_click={() => alert("Uploading!")}>Upload Image</button> + </div> + ); +} +``` + +```css +.Toolbar { + background: #aaa; + padding: 5px; +} +button { + margin: 5px; +} +``` + +If you click on either button, its `on_click` will run first, followed by the parent `<div>`'s `on_click`. So two messages will appear. If you click the toolbar itself, only the parent `<div>`'s `on_click` will run. + +<Pitfall> + +All events propagate in React except `onScroll`, which only works on the JSX tag you attach it to. + +</Pitfall> + +### Stopping propagation + +Event handlers receive an **event object** as their only argument. By convention, it's usually called `e`, which stands for "event". You can use this object to read information about the event. + +That event object also lets you stop the propagation. If you want to prevent an event from reaching parent components, you need to call `e.stopPropagation()` like this `Button` component does: + +```js +function Button({ on_click, children }) { + return ( + <button + on_click={(e) => { + e.stopPropagation(); + on_click(); + }} + > + {children} + </button> + ); +} + +export default function Toolbar() { + return ( + <div + className="Toolbar" + on_click={() => { + alert("You clicked on the toolbar!"); + }} + > + <Button on_click={() => alert("Playing!")}>Play Movie</Button> + <Button on_click={() => alert("Uploading!")}>Upload Image</Button> + </div> + ); +} +``` + +```css +.Toolbar { + background: #aaa; + padding: 5px; +} +button { + margin: 5px; +} +``` + +When you click on a button: + +1. React calls the `on_click` handler passed to `<button>`. +2. That handler, defined in `Button`, does the following: + - Calls `e.stopPropagation()`, preventing the event from bubbling further. + - Calls the `on_click` function, which is a prop passed from the `Toolbar` component. +3. That function, defined in the `Toolbar` component, displays the button's own alert. +4. Since the propagation was stopped, the parent `<div>`'s `on_click` handler does _not_ run. + +As a result of `e.stopPropagation()`, clicking on the buttons now only shows a single alert (from the `<button>`) rather than the two of them (from the `<button>` and the parent toolbar `<div>`). Clicking a button is not the same thing as clicking the surrounding toolbar, so stopping the propagation makes sense for this UI. + +<DeepDive> + +#### Capture phase events + +In rare cases, you might need to catch all events on child elements, _even if they stopped propagation_. For example, maybe you want to log every click to analytics, regardless of the propagation logic. You can do this by adding `Capture` at the end of the event name: + +```js +<div + on_clickCapture={() => { + /* this runs first */ + }} +> + <button on_click={(e) => e.stopPropagation()} /> + <button on_click={(e) => e.stopPropagation()} /> +</div> +``` + +Each event propagates in three phases: + +1. It travels down, calling all `on_clickCapture` handlers. +2. It runs the clicked element's `on_click` handler. +3. It travels upwards, calling all `on_click` handlers. + +Capture events are useful for code like routers or analytics, but you probably won't use them in app code. + +</DeepDive> + +### Passing handlers as alternative to propagation + +Notice how this click handler runs a line of code _and then_ calls the `on_click` prop passed by the parent: + +```js +function Button({ on_click, children }) { + return ( + <button + on_click={(e) => { + e.stopPropagation(); + on_click(); + }} + > + {children} + </button> + ); +} +``` + +You could add more code to this handler before calling the parent `on_click` event handler, too. This pattern provides an _alternative_ to propagation. It lets the child component handle the event, while also letting the parent component specify some additional behavior. Unlike propagation, it's not automatic. But the benefit of this pattern is that you can clearly follow the whole chain of code that executes as a result of some event. + +If you rely on propagation and it's difficult to trace which handlers execute and why, try this approach instead. + +### Preventing default behavior + +Some browser events have default behavior associated with them. For example, a `<form>` submit event, which happens when a button inside of it is clicked, will reload the whole page by default: + +```js +export default function Signup() { + return ( + <form onSubmit={() => alert("Submitting!")}> + <input /> + <button>Send</button> + </form> + ); +} +``` + +```css +button { + margin-left: 5px; +} +``` + +You can call `e.preventDefault()` on the event object to stop this from happening: + +```js +export default function Signup() { + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + alert("Submitting!"); + }} + > + <input /> + <button>Send</button> + </form> + ); +} +``` + +```css +button { + margin-left: 5px; +} +``` + +Don't confuse `e.stopPropagation()` and `e.preventDefault()`. They are both useful, but are unrelated: + +- [`e.stopPropagation()`](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) stops the event handlers attached to the tags above from firing. +- [`e.preventDefault()` ](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) prevents the default browser behavior for the few events that have it. + +## Can event handlers have side effects? + +Absolutely! Event handlers are the best place for side effects. + +Unlike rendering functions, event handlers don't need to be [pure](/learn/keeping-components-pure), so it's a great place to _change_ something—for example, change an input's value in response to typing, or change a list in response to a button press. However, in order to change some information, you first need some way to store it. In React, this is done by using [state, a component's memory.](/learn/state-a-components-memory) You will learn all about it on the next page. + +<Recap> + +- You can handle events by passing a function as a prop to an element like `<button>`. +- Event handlers must be passed, **not called!** `on_click={handleClick}`, not `on_click={handleClick()}`. +- You can define an event handler function separately or inline. +- Event handlers are defined inside a component, so they can access props. +- You can declare an event handler in a parent and pass it as a prop to a child. +- You can define your own event handler props with application-specific names. +- Events propagate upwards. Call `e.stopPropagation()` on the first argument to prevent that. +- Events may have unwanted default browser behavior. Call `e.preventDefault()` to prevent that. +- Explicitly calling an event handler prop from a child handler is a good alternative to propagation. + +</Recap> + +<Challenges> + +#### Fix an event handler + +Clicking this button is supposed to switch the page background between white and black. However, nothing happens when you click it. Fix the problem. (Don't worry about the logic inside `handleClick`—that part is fine.) + +```js +export default function LightSwitch() { + function handleClick() { + let bodyStyle = document.body.style; + if (bodyStyle.backgroundColor === "black") { + bodyStyle.backgroundColor = "white"; + } else { + bodyStyle.backgroundColor = "black"; + } + } + + return <button on_click={handleClick()}>Toggle the lights</button>; +} +``` + +<Solution> + +The problem is that `<button on_click={handleClick()}>` _calls_ the `handleClick` function while rendering instead of _passing_ it. Removing the `()` call so that it's `<button on_click={handleClick}>` fixes the issue: + +```js +export default function LightSwitch() { + function handleClick() { + let bodyStyle = document.body.style; + if (bodyStyle.backgroundColor === "black") { + bodyStyle.backgroundColor = "white"; + } else { + bodyStyle.backgroundColor = "black"; + } + } + + return <button on_click={handleClick}>Toggle the lights</button>; +} +``` + +Alternatively, you could wrap the call into another function, like `<button on_click={() => handleClick()}>`: + +```js +export default function LightSwitch() { + function handleClick() { + let bodyStyle = document.body.style; + if (bodyStyle.backgroundColor === "black") { + bodyStyle.backgroundColor = "white"; + } else { + bodyStyle.backgroundColor = "black"; + } + } + + return <button on_click={() => handleClick()}>Toggle the lights</button>; +} +``` + +</Solution> + +#### Wire up the events + +This `ColorSwitch` component renders a button. It's supposed to change the page color. Wire it up to the `onChangeColor` event handler prop it receives from the parent so that clicking the button changes the color. + +After you do this, notice that clicking the button also increments the page click counter. Your colleague who wrote the parent component insists that `onChangeColor` does not increment any counters. What else might be happening? Fix it so that clicking the button _only_ changes the color, and does _not_ increment the counter. + +```js +export default function ColorSwitch({ onChangeColor }) { + return <button>Change color</button>; +} +``` + +```js +import { useState } from "react"; +import ColorSwitch from "./ColorSwitch.js"; + +export default function App() { + const [clicks, setClicks] = useState(0); + + function handleClickOutside() { + setClicks((c) => c + 1); + } + + function getRandomLightColor() { + let r = 150 + Math.round(100 * Math.random()); + let g = 150 + Math.round(100 * Math.random()); + let b = 150 + Math.round(100 * Math.random()); + return `rgb(${r}, ${g}, ${b})`; + } + + function handleChangeColor() { + let bodyStyle = document.body.style; + bodyStyle.backgroundColor = getRandomLightColor(); + } + + return ( + <div + style={{ width: "100%", height: "100%" }} + on_click={handleClickOutside} + > + <ColorSwitch onChangeColor={handleChangeColor} /> + <br /> + <br /> + <h2>Clicks on the page: {clicks}</h2> + </div> + ); +} +``` + +<Solution> + +First, you need to add the event handler, like `<button on_click={onChangeColor}>`. + +However, this introduces the problem of the incrementing counter. If `onChangeColor` does not do this, as your colleague insists, then the problem is that this event propagates up, and some handler above does it. To solve this problem, you need to stop the propagation. But don't forget that you should still call `onChangeColor`. + +```js +export default function ColorSwitch({ onChangeColor }) { + return ( + <button + on_click={(e) => { + e.stopPropagation(); + onChangeColor(); + }} + > + Change color + </button> + ); +} +``` + +```js +import { useState } from "react"; +import ColorSwitch from "./ColorSwitch.js"; + +export default function App() { + const [clicks, setClicks] = useState(0); + + function handleClickOutside() { + setClicks((c) => c + 1); + } + + function getRandomLightColor() { + let r = 150 + Math.round(100 * Math.random()); + let g = 150 + Math.round(100 * Math.random()); + let b = 150 + Math.round(100 * Math.random()); + return `rgb(${r}, ${g}, ${b})`; + } + + function handleChangeColor() { + let bodyStyle = document.body.style; + bodyStyle.backgroundColor = getRandomLightColor(); + } + + return ( + <div + style={{ width: "100%", height: "100%" }} + on_click={handleClickOutside} + > + <ColorSwitch onChangeColor={handleChangeColor} /> + <br /> + <br /> + <h2>Clicks on the page: {clicks}</h2> + </div> + ); +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/reusing-logic-with-custom-hooks.md b/docs/src/learn/reusing-logic-with-custom-hooks.md new file mode 100644 index 000000000..068398d75 --- /dev/null +++ b/docs/src/learn/reusing-logic-with-custom-hooks.md @@ -0,0 +1,2515 @@ +## Overview + +<p class="intro" markdown> + +React comes with several built-in Hooks like `useState`, `useContext`, and `useEffect`. Sometimes, you'll wish that there was a Hook for some more specific purpose: for example, to fetch data, to keep track of whether the user is online, or to connect to a chat room. You might not find these Hooks in React, but you can create your own Hooks for your application's needs. + +</p> + +!!! summary "You will learn" + + - What custom Hooks are, and how to write your own + - How to reuse logic between components + - How to name and structure your custom Hooks + - When and why to extract custom Hooks + +## Custom Hooks: Sharing logic between components + +Imagine you're developing an app that heavily relies on the network (as most apps do). You want to warn the user if their network connection has accidentally gone off while they were using your app. How would you go about it? It seems like you'll need two things in your component: + +1. A piece of state that tracks whether the network is online. +2. An Effect that subscribes to the global [`online`](https://developer.mozilla.org/en-US/docs/Web/API/Window/online_event) and [`offline`](https://developer.mozilla.org/en-US/docs/Web/API/Window/offline_event) events, and updates that state. + +This will keep your component [synchronized](/learn/synchronizing-with-effects) with the network status. You might start with something like this: + +```js +import { useState, useEffect } from "react"; + +export default function StatusBar() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} +``` + +Try turning your network on and off, and notice how this `StatusBar` updates in response to your actions. + +Now imagine you _also_ want to use the same logic in a different component. You want to implement a Save button that will become disabled and show "Reconnecting..." instead of "Save" while the network is off. + +To start, you can copy and paste the `isOnline` state and the Effect into `SaveButton`: + +```js +import { useState, useEffect } from "react"; + +export default function SaveButton() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + function handleSaveClick() { + console.log("✅ Progress saved"); + } + + return ( + <button disabled={!isOnline} on_click={handleSaveClick}> + {isOnline ? "Save progress" : "Reconnecting..."} + </button> + ); +} +``` + +Verify that, if you turn off the network, the button will change its appearance. + +These two components work fine, but the duplication in logic between them is unfortunate. It seems like even though they have different _visual appearance,_ you want to reuse the logic between them. + +### Extracting your own custom Hook from a component + +Imagine for a moment that, similar to [`useState`](/reference/react/useState) and [`useEffect`](/reference/react/useEffect), there was a built-in `useOnlineStatus` Hook. Then both of these components could be simplified and you could remove the duplication between them: + +```js +function StatusBar() { + const isOnline = useOnlineStatus(); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log("✅ Progress saved"); + } + + return ( + <button disabled={!isOnline} on_click={handleSaveClick}> + {isOnline ? "Save progress" : "Reconnecting..."} + </button> + ); +} +``` + +Although there is no such built-in Hook, you can write it yourself. Declare a function called `useOnlineStatus` and move all the duplicated code into it from the components you wrote earlier: + +```js +function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + return isOnline; +} +``` + +At the end of the function, return `isOnline`. This lets your components read that value: + +```js +import { useOnlineStatus } from "./useOnlineStatus.js"; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log("✅ Progress saved"); + } + + return ( + <button disabled={!isOnline} on_click={handleSaveClick}> + {isOnline ? "Save progress" : "Reconnecting..."} + </button> + ); +} + +export default function App() { + return ( + <> + <SaveButton /> + <StatusBar /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + return isOnline; +} +``` + +Verify that switching the network on and off updates both components. + +Now your components don't have as much repetitive logic. **More importantly, the code inside them describes _what they want to do_ (use the online status!) rather than _how to do it_ (by subscribing to the browser events).** + +When you extract logic into custom Hooks, you can hide the gnarly details of how you deal with some external system or a browser API. The code of your components expresses your intent, not the implementation. + +### Hook names always start with `use` + +React applications are built from components. Components are built from Hooks, whether built-in or custom. You'll likely often use custom Hooks created by others, but occasionally you might write one yourself! + +You must follow these naming conventions: + +1. **React component names must start with a capital letter,** like `StatusBar` and `SaveButton`. React components also need to return something that React knows how to display, like a piece of JSX. +2. **Hook names must start with `use` followed by a capital letter,** like [`useState`](/reference/react/useState) (built-in) or `useOnlineStatus` (custom, like earlier on the page). Hooks may return arbitrary values. + +This convention guarantees that you can always look at a component and know where its state, Effects, and other React features might "hide". For example, if you see a `getColor()` function call inside your component, you can be sure that it can't possibly contain React state inside because its name doesn't start with `use`. However, a function call like `useOnlineStatus()` will most likely contain calls to other Hooks inside! + +<Note> + +If your linter is [configured for React,](/learn/editor-setup#linting) it will enforce this naming convention. Scroll up to the sandbox above and rename `useOnlineStatus` to `getOnlineStatus`. Notice that the linter won't allow you to call `useState` or `useEffect` inside of it anymore. Only Hooks and components can call other Hooks! + +</Note> + +<DeepDive> + +#### Should all functions called during rendering start with the use prefix? + +No. Functions that don't _call_ Hooks don't need to _be_ Hooks. + +If your function doesn't call any Hooks, avoid the `use` prefix. Instead, write it as a regular function _without_ the `use` prefix. For example, `useSorted` below doesn't call Hooks, so call it `getSorted` instead: + +```js +// 🔴 Avoid: A Hook that doesn't use Hooks +function useSorted(items) { + return items.slice().sort(); +} + +// ✅ Good: A regular function that doesn't use Hooks +function getSorted(items) { + return items.slice().sort(); +} +``` + +This ensures that your code can call this regular function anywhere, including conditions: + +```js +function List({ items, shouldSort }) { + let displayedItems = items; + if (shouldSort) { + // ✅ It's ok to call getSorted() conditionally because it's not a Hook + displayedItems = getSorted(items); + } + // ... +} +``` + +You should give `use` prefix to a function (and thus make it a Hook) if it uses at least one Hook inside of it: + +```js +// ✅ Good: A Hook that uses other Hooks +function useAuth() { + return useContext(Auth); +} +``` + +Technically, this isn't enforced by React. In principle, you could make a Hook that doesn't call other Hooks. This is often confusing and limiting so it's best to avoid that pattern. However, there may be rare cases where it is helpful. For example, maybe your function doesn't use any Hooks right now, but you plan to add some Hook calls to it in the future. Then it makes sense to name it with the `use` prefix: + +```js +// ✅ Good: A Hook that will likely use some other Hooks later +function useAuth() { + // TODO: Replace with this line when authentication is implemented: + // return useContext(Auth); + return TEST_USER; +} +``` + +Then components won't be able to call it conditionally. This will become important when you actually add Hook calls inside. If you don't plan to use Hooks inside it (now or later), don't make it a Hook. + +</DeepDive> + +### Custom Hooks let you share stateful logic, not state itself + +In the earlier example, when you turned the network on and off, both components updated together. However, it's wrong to think that a single `isOnline` state variable is shared between them. Look at this code: + +```js +function StatusBar() { + const isOnline = useOnlineStatus(); + // ... +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + // ... +} +``` + +It works the same way as before you extracted the duplication: + +```js +function StatusBar() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + // ... + }, []); + // ... +} + +function SaveButton() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + // ... + }, []); + // ... +} +``` + +These are two completely independent state variables and Effects! They happened to have the same value at the same time because you synchronized them with the same external value (whether the network is on). + +To better illustrate this, we'll need a different example. Consider this `Form` component: + +```js +import { useState } from "react"; + +export default function Form() { + const [firstName, setFirstName] = useState("Mary"); + const [lastName, setLastName] = useState("Poppins"); + + function handleFirstNameChange(e) { + setFirstName(e.target.value); + } + + function handleLastNameChange(e) { + setLastName(e.target.value); + } + + return ( + <> + <label> + First name: + <input value={firstName} onChange={handleFirstNameChange} /> + </label> + <label> + Last name: + <input value={lastName} onChange={handleLastNameChange} /> + </label> + <p> + <b> + Good morning, {firstName} {lastName}. + </b> + </p> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 10px; +} +``` + +There's some repetitive logic for each form field: + +1. There's a piece of state (`firstName` and `lastName`). +1. There's a change handler (`handleFirstNameChange` and `handleLastNameChange`). +1. There's a piece of JSX that specifies the `value` and `onChange` attributes for that input. + +You can extract the repetitive logic into this `useFormInput` custom Hook: + +```js +import { useFormInput } from "./useFormInput.js"; + +export default function Form() { + const firstNameProps = useFormInput("Mary"); + const lastNameProps = useFormInput("Poppins"); + + return ( + <> + <label> + First name: + <input {...firstNameProps} /> + </label> + <label> + Last name: + <input {...lastNameProps} /> + </label> + <p> + <b> + Good morning, {firstNameProps.value} {lastNameProps.value}. + </b> + </p> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export function useFormInput(initialValue) { + const [value, setValue] = useState(initialValue); + + function handleChange(e) { + setValue(e.target.value); + } + + const inputProps = { + value: value, + onChange: handleChange, + }; + + return inputProps; +} +``` + +```css +label { + display: block; +} +input { + margin-left: 10px; +} +``` + +Notice that it only declares _one_ state variable called `value`. + +However, the `Form` component calls `useFormInput` _two times:_ + +```js +function Form() { + const firstNameProps = useFormInput('Mary'); + const lastNameProps = useFormInput('Poppins'); + // ... +``` + +This is why it works like declaring two separate state variables! + +**Custom Hooks let you share _stateful logic_ but not _state itself._ Each call to a Hook is completely independent from every other call to the same Hook.** This is why the two sandboxes above are completely equivalent. If you'd like, scroll back up and compare them. The behavior before and after extracting a custom Hook is identical. + +When you need to share the state itself between multiple components, [lift it up and pass it down](/learn/sharing-state-between-components) instead. + +## Passing reactive values between Hooks + +The code inside your custom Hooks will re-run during every re-render of your component. This is why, like components, custom Hooks [need to be pure.](/learn/keeping-components-pure) Think of custom Hooks' code as part of your component's body! + +Because custom Hooks re-render together with your component, they always receive the latest props and state. To see what this means, consider this chat room example. Change the server URL or the chat room: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.on("message", (msg) => { + showNotification("New message: " + msg); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); + + return ( + <> + <label> + Server URL: + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ Disconnected from "' + + roomId + + '" room at ' + + serverUrl + + "" + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme = "dark") { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```json +{ + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +When you change `serverUrl` or `roomId`, the Effect ["reacts" to your changes](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) and re-synchronizes. You can tell by the console messages that the chat re-connects every time that you change your Effect's dependencies. + +Now move the Effect's code into a custom Hook: + +```js +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + connection.on("message", (msg) => { + showNotification("New message: " + msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +This lets your `ChatRoom` component call your custom Hook without worrying about how it works inside: + +```js +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + }); + + return ( + <> + <label> + Server URL: + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} +``` + +This looks much simpler! (But it does the same thing.) + +Notice that the logic _still responds_ to prop and state changes. Try editing the server URL or the selected room: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +import { useState } from "react"; +import { useChatRoom } from "./useChatRoom.js"; + +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + }); + + return ( + <> + <label> + Server URL: + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} +``` + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + connection.on("message", (msg) => { + showNotification("New message: " + msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ Disconnected from "' + + roomId + + '" room at ' + + serverUrl + + "" + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme = "dark") { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```json +{ + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Notice how you're taking the return value of one Hook: + +```js +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + // ... +``` + +and pass it as an input to another Hook: + +```js +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + // ... +``` + +Every time your `ChatRoom` component re-renders, it passes the latest `roomId` and `serverUrl` to your Hook. This is why your Effect re-connects to the chat whenever their values are different after a re-render. (If you ever worked with audio or video processing software, chaining Hooks like this might remind you of chaining visual or audio effects. It's as if the output of `useState` "feeds into" the input of the `useChatRoom`.) + +### Passing event handlers to custom Hooks + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +As you start using `useChatRoom` in more components, you might want to let components customize its behavior. For example, currently, the logic for what to do when a message arrives is hardcoded inside the Hook: + +```js +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + connection.on("message", (msg) => { + showNotification("New message: " + msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +Let's say you want to move this logic back to your component: + +```js +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + onReceiveMessage(msg) { + showNotification('New message: ' + msg); + } + }); + // ... +``` + +To make this work, change your custom Hook to take `onReceiveMessage` as one of its named options: + +```js +export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + connection.on("message", (msg) => { + onReceiveMessage(msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl, onReceiveMessage]); // ✅ All dependencies declared +} +``` + +This will work, but there's one more improvement you can do when your custom Hook accepts event handlers. + +Adding a dependency on `onReceiveMessage` is not ideal because it will cause the chat to re-connect every time the component re-renders. [Wrap this event handler into an Effect Event to remove it from the dependencies:](/learn/removing-effect-dependencies#wrapping-an-event-handler-from-the-props) + +```js +import { useEffect, useEffectEvent } from "react"; +// ... + +export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) { + const onMessage = useEffectEvent(onReceiveMessage); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + connection.on("message", (msg) => { + onMessage(msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); // ✅ All dependencies declared +} +``` + +Now the chat won't re-connect every time that the `ChatRoom` component re-renders. Here is a fully working demo of passing an event handler to a custom Hook that you can play with: + +```js +import { useState } from "react"; +import ChatRoom from "./ChatRoom.js"; + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +import { useState } from "react"; +import { useChatRoom } from "./useChatRoom.js"; +import { showNotification } from "./notifications.js"; + +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + onReceiveMessage(msg) { + showNotification("New message: " + msg); + }, + }); + + return ( + <> + <label> + Server URL: + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} +``` + +```js +import { useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { createConnection } from "./chat.js"; + +export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) { + const onMessage = useEffectEvent(onReceiveMessage); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + connection.on("message", (msg) => { + onMessage(msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== "string") { + throw Error( + "Expected serverUrl to be a string. Received: " + serverUrl + ); + } + if (typeof roomId !== "string") { + throw Error("Expected roomId to be a string. Received: " + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback("hey"); + } else { + messageCallback("lol"); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log( + '❌ Disconnected from "' + + roomId + + '" room at ' + + serverUrl + + "" + ); + }, + on(event, callback) { + if (messageCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "message") { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme = "dark") { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Notice how you no longer need to know _how_ `useChatRoom` works in order to use it. You could add it to any other component, pass any other options, and it would work the same way. That's the power of custom Hooks. + +## When to use custom Hooks + +You don't need to extract a custom Hook for every little duplicated bit of code. Some duplication is fine. For example, extracting a `useFormInput` Hook to wrap a single `useState` call like earlier is probably unnecessary. + +However, whenever you write an Effect, consider whether it would be clearer to also wrap it in a custom Hook. [You shouldn't need Effects very often,](/learn/you-might-not-need-an-effect) so if you're writing one, it means that you need to "step outside React" to synchronize with some external system or to do something that React doesn't have a built-in API for. Wrapping it into a custom Hook lets you precisely communicate your intent and how the data flows through it. + +For example, consider a `ShippingForm` component that displays two dropdowns: one shows the list of cities, and another shows the list of areas in the selected city. You might start with some code that looks like this: + +```js +function ShippingForm({ country }) { + const [cities, setCities] = useState(null); + // This Effect fetches cities for a country + useEffect(() => { + let ignore = false; + fetch(`/api/cities?country=${country}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setCities(json); + } + }); + return () => { + ignore = true; + }; + }, [country]); + + const [city, setCity] = useState(null); + const [areas, setAreas] = useState(null); + // This Effect fetches areas for the selected city + useEffect(() => { + if (city) { + let ignore = false; + fetch(`/api/areas?city=${city}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setAreas(json); + } + }); + return () => { + ignore = true; + }; + } + }, [city]); + + // ... +``` + +Although this code is quite repetitive, [it's correct to keep these Effects separate from each other.](/learn/removing-effect-dependencies#is-your-effect-doing-several-unrelated-things) They synchronize two different things, so you shouldn't merge them into one Effect. Instead, you can simplify the `ShippingForm` component above by extracting the common logic between them into your own `useData` Hook: + +```js +function useData(url) { + const [data, setData] = useState(null); + useEffect(() => { + if (url) { + let ignore = false; + fetch(url) + .then((response) => response.json()) + .then((json) => { + if (!ignore) { + setData(json); + } + }); + return () => { + ignore = true; + }; + } + }, [url]); + return data; +} +``` + +Now you can replace both Effects in the `ShippingForm` components with calls to `useData`: + +```js +function ShippingForm({ country }) { + const cities = useData(`/api/cities?country=${country}`); + const [city, setCity] = useState(null); + const areas = useData(city ? `/api/areas?city=${city}` : null); + // ... +``` + +Extracting a custom Hook makes the data flow explicit. You feed the `url` in and you get the `data` out. By "hiding" your Effect inside `useData`, you also prevent someone working on the `ShippingForm` component from adding [unnecessary dependencies](/learn/removing-effect-dependencies) to it. With time, most of your app's Effects will be in custom Hooks. + +<DeepDive> + +#### Keep your custom Hooks focused on concrete high-level use cases + +Start by choosing your custom Hook's name. If you struggle to pick a clear name, it might mean that your Effect is too coupled to the rest of your component's logic, and is not yet ready to be extracted. + +Ideally, your custom Hook's name should be clear enough that even a person who doesn't write code often could have a good guess about what your custom Hook does, what it takes, and what it returns: + +- ✅ `useData(url)` +- ✅ `useImpressionLog(eventName, extraData)` +- ✅ `useChatRoom(options)` + +When you synchronize with an external system, your custom Hook name may be more technical and use jargon specific to that system. It's good as long as it would be clear to a person familiar with that system: + +- ✅ `useMediaQuery(query)` +- ✅ `useSocket(url)` +- ✅ `useIntersectionObserver(ref, options)` + +**Keep custom Hooks focused on concrete high-level use cases.** Avoid creating and using custom "lifecycle" Hooks that act as alternatives and convenience wrappers for the `useEffect` API itself: + +- 🔴 `useMount(fn)` +- 🔴 `useEffectOnce(fn)` +- 🔴 `useUpdateEffect(fn)` + +For example, this `useMount` Hook tries to ensure some code only runs "on mount": + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + // 🔴 Avoid: using custom "lifecycle" Hooks + useMount(() => { + const connection = createConnection({ roomId, serverUrl }); + connection.connect(); + + post("/analytics/event", { eventName: "visit_chat" }); + }); + // ... +} + +// 🔴 Avoid: creating custom "lifecycle" Hooks +function useMount(fn) { + useEffect(() => { + fn(); + }, []); // 🔴 React Hook useEffect has a missing dependency: 'fn' +} +``` + +**Custom "lifecycle" Hooks like `useMount` don't fit well into the React paradigm.** For example, this code example has a mistake (it doesn't "react" to `roomId` or `serverUrl` changes), but the linter won't warn you about it because the linter only checks direct `useEffect` calls. It won't know about your Hook. + +If you're writing an Effect, start by using the React API directly: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + // ✅ Good: two raw Effects separated by purpose + + useEffect(() => { + const connection = createConnection({ serverUrl, roomId }); + connection.connect(); + return () => connection.disconnect(); + }, [serverUrl, roomId]); + + useEffect(() => { + post("/analytics/event", { eventName: "visit_chat", roomId }); + }, [roomId]); + + // ... +} +``` + +Then, you can (but don't have to) extract custom Hooks for different high-level use cases: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + // ✅ Great: custom Hooks named after their purpose + useChatRoom({ serverUrl, roomId }); + useImpressionLog("visit_chat", { roomId }); + // ... +} +``` + +**A good custom Hook makes the calling code more declarative by constraining what it does.** For example, `useChatRoom(options)` can only connect to the chat room, while `useImpressionLog(eventName, extraData)` can only send an impression log to the analytics. If your custom Hook API doesn't constrain the use cases and is very abstract, in the long run it's likely to introduce more problems than it solves. + +</DeepDive> + +### Custom Hooks help you migrate to better patterns + +Effects are an ["escape hatch"](/learn/escape-hatches): you use them when you need to "step outside React" and when there is no better built-in solution for your use case. With time, the React team's goal is to reduce the number of the Effects in your app to the minimum by providing more specific solutions to more specific problems. Wrapping your Effects in custom Hooks makes it easier to upgrade your code when these solutions become available. + +Let's return to this example: + +```js +import { useOnlineStatus } from "./useOnlineStatus.js"; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log("✅ Progress saved"); + } + + return ( + <button disabled={!isOnline} on_click={handleSaveClick}> + {isOnline ? "Save progress" : "Reconnecting..."} + </button> + ); +} + +export default function App() { + return ( + <> + <SaveButton /> + <StatusBar /> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + return isOnline; +} +``` + +In the above example, `useOnlineStatus` is implemented with a pair of [`useState`](/reference/react/useState) and [`useEffect`.](/reference/react/useEffect) However, this isn't the best possible solution. There is a number of edge cases it doesn't consider. For example, it assumes that when the component mounts, `isOnline` is already `true`, but this may be wrong if the network already went offline. You can use the browser [`navigator.onLine`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine) API to check for that, but using it directly would not work on the server for generating the initial HTML. In short, this code could be improved. + +Luckily, React 18 includes a dedicated API called [`useSyncExternalStore`](/reference/react/useSyncExternalStore) which takes care of all of these problems for you. Here is how your `useOnlineStatus` Hook, rewritten to take advantage of this new API: + +```js +import { useOnlineStatus } from "./useOnlineStatus.js"; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log("✅ Progress saved"); + } + + return ( + <button disabled={!isOnline} on_click={handleSaveClick}> + {isOnline ? "Save progress" : "Reconnecting..."} + </button> + ); +} + +export default function App() { + return ( + <> + <SaveButton /> + <StatusBar /> + </> + ); +} +``` + +```js +import { useSyncExternalStore } from "react"; + +function subscribe(callback) { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} + +export function useOnlineStatus() { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, // How to get the value on the client + () => true // How to get the value on the server + ); +} +``` + +Notice how **you didn't need to change any of the components** to make this migration: + +```js +function StatusBar() { + const isOnline = useOnlineStatus(); + // ... +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + // ... +} +``` + +This is another reason for why wrapping Effects in custom Hooks is often beneficial: + +1. You make the data flow to and from your Effects very explicit. +2. You let your components focus on the intent rather than on the exact implementation of your Effects. +3. When React adds new features, you can remove those Effects without changing any of your components. + +Similar to a [design system,](https://uxdesign.cc/everything-you-need-to-know-about-design-systems-54b109851969) you might find it helpful to start extracting common idioms from your app's components into custom Hooks. This will keep your components' code focused on the intent, and let you avoid writing raw Effects very often. Many excellent custom Hooks are maintained by the React community. + +<DeepDive> + +#### Will React provide any built-in solution for data fetching? + +We're still working out the details, but we expect that in the future, you'll write data fetching like this: + +```js +import { use } from 'react'; // Not available yet! + +function ShippingForm({ country }) { + const cities = use(fetch(`/api/cities?country=${country}`)); + const [city, setCity] = useState(null); + const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null; + // ... +``` + +If you use custom Hooks like `useData` above in your app, it will require fewer changes to migrate to the eventually recommended approach than if you write raw Effects in every component manually. However, the old approach will still work fine, so if you feel happy writing raw Effects, you can continue to do that. + +</DeepDive> + +### There is more than one way to do it + +Let's say you want to implement a fade-in animation _from scratch_ using the browser [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) API. You might start with an Effect that sets up an animation loop. During each frame of the animation, you could change the opacity of the DOM node you [hold in a ref](/learn/manipulating-the-dom-with-refs) until it reaches `1`. Your code might start like this: + +```js +import { useState, useEffect, useRef } from "react"; + +function Welcome() { + const ref = useRef(null); + + useEffect(() => { + const duration = 1000; + const node = ref.current; + + let startTime = performance.now(); + let frameId = null; + + function onFrame(now) { + const timePassed = now - startTime; + const progress = Math.min(timePassed / duration, 1); + onProgress(progress); + if (progress < 1) { + // We still have more frames to paint + frameId = requestAnimationFrame(onFrame); + } + } + + function onProgress(progress) { + node.style.opacity = progress; + } + + function start() { + onProgress(0); + startTime = performance.now(); + frameId = requestAnimationFrame(onFrame); + } + + function stop() { + cancelAnimationFrame(frameId); + startTime = null; + frameId = null; + } + + start(); + return () => stop(); + }, []); + + return ( + <h1 className="welcome" ref={ref}> + Welcome + </h1> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +.welcome { + opacity: 0; + color: white; + padding: 50px; + text-align: center; + font-size: 50px; + background-image: radial-gradient( + circle, + rgba(63, 94, 251, 1) 0%, + rgba(252, 70, 107, 1) 100% + ); +} +``` + +To make the component more readable, you might extract the logic into a `useFadeIn` custom Hook: + +```js +import { useState, useEffect, useRef } from "react"; +import { useFadeIn } from "./useFadeIn.js"; + +function Welcome() { + const ref = useRef(null); + + useFadeIn(ref, 1000); + + return ( + <h1 className="welcome" ref={ref}> + Welcome + </h1> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```js +import { useEffect } from "react"; + +export function useFadeIn(ref, duration) { + useEffect(() => { + const node = ref.current; + + let startTime = performance.now(); + let frameId = null; + + function onFrame(now) { + const timePassed = now - startTime; + const progress = Math.min(timePassed / duration, 1); + onProgress(progress); + if (progress < 1) { + // We still have more frames to paint + frameId = requestAnimationFrame(onFrame); + } + } + + function onProgress(progress) { + node.style.opacity = progress; + } + + function start() { + onProgress(0); + startTime = performance.now(); + frameId = requestAnimationFrame(onFrame); + } + + function stop() { + cancelAnimationFrame(frameId); + startTime = null; + frameId = null; + } + + start(); + return () => stop(); + }, [ref, duration]); +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +.welcome { + opacity: 0; + color: white; + padding: 50px; + text-align: center; + font-size: 50px; + background-image: radial-gradient( + circle, + rgba(63, 94, 251, 1) 0%, + rgba(252, 70, 107, 1) 100% + ); +} +``` + +You could keep the `useFadeIn` code as is, but you could also refactor it more. For example, you could extract the logic for setting up the animation loop out of `useFadeIn` into a custom `useAnimationLoop` Hook: + +```js +import { useState, useEffect, useRef } from "react"; +import { useFadeIn } from "./useFadeIn.js"; + +function Welcome() { + const ref = useRef(null); + + useFadeIn(ref, 1000); + + return ( + <h1 className="welcome" ref={ref}> + Welcome + </h1> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export function useFadeIn(ref, duration) { + const [isRunning, setIsRunning] = useState(true); + + useAnimationLoop(isRunning, (timePassed) => { + const progress = Math.min(timePassed / duration, 1); + ref.current.style.opacity = progress; + if (progress === 1) { + setIsRunning(false); + } + }); +} + +function useAnimationLoop(isRunning, drawFrame) { + const onFrame = useEffectEvent(drawFrame); + + useEffect(() => { + if (!isRunning) { + return; + } + + const startTime = performance.now(); + let frameId = null; + + function tick(now) { + const timePassed = now - startTime; + onFrame(timePassed); + frameId = requestAnimationFrame(tick); + } + + tick(); + return () => cancelAnimationFrame(frameId); + }, [isRunning]); +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +.welcome { + opacity: 0; + color: white; + padding: 50px; + text-align: center; + font-size: 50px; + background-image: radial-gradient( + circle, + rgba(63, 94, 251, 1) 0%, + rgba(252, 70, 107, 1) 100% + ); +} +``` + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +However, you didn't _have to_ do that. As with regular functions, ultimately you decide where to draw the boundaries between different parts of your code. You could also take a very different approach. Instead of keeping the logic in the Effect, you could move most of the imperative logic inside a JavaScript [class:](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) + +```js +import { useState, useEffect, useRef } from "react"; +import { useFadeIn } from "./useFadeIn.js"; + +function Welcome() { + const ref = useRef(null); + + useFadeIn(ref, 1000); + + return ( + <h1 className="welcome" ref={ref}> + Welcome + </h1> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; +import { FadeInAnimation } from "./animation.js"; + +export function useFadeIn(ref, duration) { + useEffect(() => { + const animation = new FadeInAnimation(ref.current); + animation.start(duration); + return () => { + animation.stop(); + }; + }, [ref, duration]); +} +``` + +```js +export class FadeInAnimation { + constructor(node) { + this.node = node; + } + start(duration) { + this.duration = duration; + this.onProgress(0); + this.startTime = performance.now(); + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + onFrame() { + const timePassed = performance.now() - this.startTime; + const progress = Math.min(timePassed / this.duration, 1); + this.onProgress(progress); + if (progress === 1) { + this.stop(); + } else { + // We still have more frames to paint + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onProgress(progress) { + this.node.style.opacity = progress; + } + stop() { + cancelAnimationFrame(this.frameId); + this.startTime = null; + this.frameId = null; + this.duration = 0; + } +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +.welcome { + opacity: 0; + color: white; + padding: 50px; + text-align: center; + font-size: 50px; + background-image: radial-gradient( + circle, + rgba(63, 94, 251, 1) 0%, + rgba(252, 70, 107, 1) 100% + ); +} +``` + +Effects let you connect React to external systems. The more coordination between Effects is needed (for example, to chain multiple animations), the more it makes sense to extract that logic out of Effects and Hooks _completely_ like in the sandbox above. Then, the code you extracted _becomes_ the "external system". This lets your Effects stay simple because they only need to send messages to the system you've moved outside React. + +The examples above assume that the fade-in logic needs to be written in JavaScript. However, this particular fade-in animation is both simpler and much more efficient to implement with a plain [CSS Animation:](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Using_CSS_animations) + +```js +import { useState, useEffect, useRef } from "react"; +import "./welcome.css"; + +function Welcome() { + return <h1 className="welcome">Welcome</h1>; +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +``` + +```css +.welcome { + color: white; + padding: 50px; + text-align: center; + font-size: 50px; + background-image: radial-gradient( + circle, + rgba(63, 94, 251, 1) 0%, + rgba(252, 70, 107, 1) 100% + ); + + animation: fadeIn 1000ms; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +``` + +Sometimes, you don't even need a Hook! + +<Recap> + +- Custom Hooks let you share logic between components. +- Custom Hooks must be named starting with `use` followed by a capital letter. +- Custom Hooks only share stateful logic, not state itself. +- You can pass reactive values from one Hook to another, and they stay up-to-date. +- All Hooks re-run every time your component re-renders. +- The code of your custom Hooks should be pure, like your component's code. +- Wrap event handlers received by custom Hooks into Effect Events. +- Don't create custom Hooks like `useMount`. Keep their purpose specific. +- It's up to you how and where to choose the boundaries of your code. + +</Recap> + +<Challenges> + +#### Extract a `useCounter` Hook + +This component uses a state variable and an Effect to display a number that increments every second. Extract this logic into a custom Hook called `useCounter`. Your goal is to make the `Counter` component implementation look exactly like this: + +```js +export default function Counter() { + const count = useCounter(); + return <h1>Seconds passed: {count}</h1>; +} +``` + +You'll need to write your custom Hook in `useCounter.js` and import it into the `Counter.js` file. + +```js +import { useState, useEffect } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + 1); + }, 1000); + return () => clearInterval(id); + }, []); + return <h1>Seconds passed: {count}</h1>; +} +``` + +```js +// Write your custom Hook in this file! +``` + +<Solution> + +Your code should look like this: + +```js +import { useCounter } from "./useCounter.js"; + +export default function Counter() { + const count = useCounter(); + return <h1>Seconds passed: {count}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useCounter() { + const [count, setCount] = useState(0); + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + 1); + }, 1000); + return () => clearInterval(id); + }, []); + return count; +} +``` + +Notice that `App.js` doesn't need to import `useState` or `useEffect` anymore. + +</Solution> + +#### Make the counter delay configurable + +In this example, there is a `delay` state variable controlled by a slider, but its value is not used. Pass the `delay` value to your custom `useCounter` Hook, and change the `useCounter` Hook to use the passed `delay` instead of hardcoding `1000` ms. + +```js +import { useState } from "react"; +import { useCounter } from "./useCounter.js"; + +export default function Counter() { + const [delay, setDelay] = useState(1000); + const count = useCounter(); + return ( + <> + <label> + Tick duration: {delay} ms + <br /> + <input + type="range" + value={delay} + min="10" + max="2000" + onChange={(e) => setDelay(Number(e.target.value))} + /> + </label> + <hr /> + <h1>Ticks: {count}</h1> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useCounter() { + const [count, setCount] = useState(0); + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + 1); + }, 1000); + return () => clearInterval(id); + }, []); + return count; +} +``` + +<Solution> + +Pass the `delay` to your Hook with `useCounter(delay)`. Then, inside the Hook, use `delay` instead of the hardcoded `1000` value. You'll need to add `delay` to your Effect's dependencies. This ensures that a change in `delay` will reset the interval. + +```js +import { useState } from "react"; +import { useCounter } from "./useCounter.js"; + +export default function Counter() { + const [delay, setDelay] = useState(1000); + const count = useCounter(delay); + return ( + <> + <label> + Tick duration: {delay} ms + <br /> + <input + type="range" + value={delay} + min="10" + max="2000" + onChange={(e) => setDelay(Number(e.target.value))} + /> + </label> + <hr /> + <h1>Ticks: {count}</h1> + </> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useCounter(delay) { + const [count, setCount] = useState(0); + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + 1); + }, delay); + return () => clearInterval(id); + }, [delay]); + return count; +} +``` + +</Solution> + +#### Extract `useInterval` out of `useCounter` + +Currently, your `useCounter` Hook does two things. It sets up an interval, and it also increments a state variable on every interval tick. Split out the logic that sets up the interval into a separate Hook called `useInterval`. It should take two arguments: the `onTick` callback, and the `delay`. After this change, your `useCounter` implementation should look like this: + +```js +export function useCounter(delay) { + const [count, setCount] = useState(0); + useInterval(() => { + setCount((c) => c + 1); + }, delay); + return count; +} +``` + +Write `useInterval` in the `useInterval.js` file and import it into the `useCounter.js` file. + +```js +import { useState } from "react"; +import { useCounter } from "./useCounter.js"; + +export default function Counter() { + const count = useCounter(1000); + return <h1>Seconds passed: {count}</h1>; +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useCounter(delay) { + const [count, setCount] = useState(0); + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + 1); + }, delay); + return () => clearInterval(id); + }, [delay]); + return count; +} +``` + +```js +// Write your Hook here! +``` + +<Solution> + +The logic inside `useInterval` should set up and clear the interval. It doesn't need to do anything else. + +```js +import { useCounter } from "./useCounter.js"; + +export default function Counter() { + const count = useCounter(1000); + return <h1>Seconds passed: {count}</h1>; +} +``` + +```js +import { useState } from "react"; +import { useInterval } from "./useInterval.js"; + +export function useCounter(delay) { + const [count, setCount] = useState(0); + useInterval(() => { + setCount((c) => c + 1); + }, delay); + return count; +} +``` + +```js +import { useEffect } from "react"; + +export function useInterval(onTick, delay) { + useEffect(() => { + const id = setInterval(onTick, delay); + return () => clearInterval(id); + }, [onTick, delay]); +} +``` + +Note that there is a bit of a problem with this solution, which you'll solve in the next challenge. + +</Solution> + +#### Fix a resetting interval + +In this example, there are _two_ separate intervals. + +The `App` component calls `useCounter`, which calls `useInterval` to update the counter every second. But the `App` component _also_ calls `useInterval` to randomly update the page background color every two seconds. + +For some reason, the callback that updates the page background never runs. Add some logs inside `useInterval`: + +```js +useEffect(() => { + console.log("✅ Setting up an interval with delay ", delay); + const id = setInterval(onTick, delay); + return () => { + console.log("❌ Clearing an interval with delay ", delay); + clearInterval(id); + }; +}, [onTick, delay]); +``` + +Do the logs match what you expect to happen? If some of your Effects seem to re-synchronize unnecessarily, can you guess which dependency is causing that to happen? Is there some way to [remove that dependency](/learn/removing-effect-dependencies) from your Effect? + +After you fix the issue, you should expect the page background to update every two seconds. + +<Hint> + +It looks like your `useInterval` Hook accepts an event listener as an argument. Can you think of some way to wrap that event listener so that it doesn't need to be a dependency of your Effect? + +</Hint> + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useCounter } from "./useCounter.js"; +import { useInterval } from "./useInterval.js"; + +export default function Counter() { + const count = useCounter(1000); + + useInterval(() => { + const randomColor = `hsla(${Math.random() * 360}, 100%, 50%, 0.2)`; + document.body.style.backgroundColor = randomColor; + }, 2000); + + return <h1>Seconds passed: {count}</h1>; +} +``` + +```js +import { useState } from "react"; +import { useInterval } from "./useInterval.js"; + +export function useCounter(delay) { + const [count, setCount] = useState(0); + useInterval(() => { + setCount((c) => c + 1); + }, delay); + return count; +} +``` + +```js +import { useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export function useInterval(onTick, delay) { + useEffect(() => { + const id = setInterval(onTick, delay); + return () => { + clearInterval(id); + }; + }, [onTick, delay]); +} +``` + +<Solution> + +Inside `useInterval`, wrap the tick callback into an Effect Event, as you did [earlier on this page.](/learn/reusing-logic-with-custom-hooks#passing-event-handlers-to-custom-hooks) + +This will allow you to omit `onTick` from dependencies of your Effect. The Effect won't re-synchronize on every re-render of the component, so the page background color change interval won't get reset every second before it has a chance to fire. + +With this change, both intervals work as expected and don't interfere with each other: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useCounter } from "./useCounter.js"; +import { useInterval } from "./useInterval.js"; + +export default function Counter() { + const count = useCounter(1000); + + useInterval(() => { + const randomColor = `hsla(${Math.random() * 360}, 100%, 50%, 0.2)`; + document.body.style.backgroundColor = randomColor; + }, 2000); + + return <h1>Seconds passed: {count}</h1>; +} +``` + +```js +import { useState } from "react"; +import { useInterval } from "./useInterval.js"; + +export function useCounter(delay) { + const [count, setCount] = useState(0); + useInterval(() => { + setCount((c) => c + 1); + }, delay); + return count; +} +``` + +```js +import { useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export function useInterval(callback, delay) { + const onTick = useEffectEvent(callback); + useEffect(() => { + const id = setInterval(onTick, delay); + return () => clearInterval(id); + }, [delay]); +} +``` + +</Solution> + +#### Implement a staggering movement + +In this example, the `usePointerPosition()` Hook tracks the current pointer position. Try moving your cursor or your finger over the preview area and see the red dot follow your movement. Its position is saved in the `pos1` variable. + +In fact, there are five (!) different red dots being rendered. You don't see them because currently they all appear at the same position. This is what you need to fix. What you want to implement instead is a "staggered" movement: each dot should "follow" the previous dot's path. For example, if you quickly move your cursor, the first dot should follow it immediately, the second dot should follow the first dot with a small delay, the third dot should follow the second dot, and so on. + +You need to implement the `useDelayedValue` custom Hook. Its current implementation returns the `value` provided to it. Instead, you want to return the value back from `delay` milliseconds ago. You might need some state and an Effect to do this. + +After you implement `useDelayedValue`, you should see the dots move following one another. + +<Hint> + +You'll need to store the `delayedValue` as a state variable inside your custom Hook. When the `value` changes, you'll want to run an Effect. This Effect should update `delayedValue` after the `delay`. You might find it helpful to call `setTimeout`. + +Does this Effect need cleanup? Why or why not? + +</Hint> + +```js +import { usePointerPosition } from "./usePointerPosition.js"; + +function useDelayedValue(value, delay) { + // TODO: Implement this Hook + return value; +} + +export default function Canvas() { + const pos1 = usePointerPosition(); + const pos2 = useDelayedValue(pos1, 100); + const pos3 = useDelayedValue(pos2, 200); + const pos4 = useDelayedValue(pos3, 100); + const pos5 = useDelayedValue(pos3, 50); + return ( + <> + <Dot position={pos1} opacity={1} /> + <Dot position={pos2} opacity={0.8} /> + <Dot position={pos3} opacity={0.6} /> + <Dot position={pos4} opacity={0.4} /> + <Dot position={pos5} opacity={0.2} /> + </> + ); +} + +function Dot({ position, opacity }) { + return ( + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function usePointerPosition() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + }, []); + return position; +} +``` + +```css +body { + min-height: 300px; +} +``` + +<Solution> + +Here is a working version. You keep the `delayedValue` as a state variable. When `value` updates, your Effect schedules a timeout to update the `delayedValue`. This is why the `delayedValue` always "lags behind" the actual `value`. + +```js +import { useState, useEffect } from "react"; +import { usePointerPosition } from "./usePointerPosition.js"; + +function useDelayedValue(value, delay) { + const [delayedValue, setDelayedValue] = useState(value); + + useEffect(() => { + setTimeout(() => { + setDelayedValue(value); + }, delay); + }, [value, delay]); + + return delayedValue; +} + +export default function Canvas() { + const pos1 = usePointerPosition(); + const pos2 = useDelayedValue(pos1, 100); + const pos3 = useDelayedValue(pos2, 200); + const pos4 = useDelayedValue(pos3, 100); + const pos5 = useDelayedValue(pos3, 50); + return ( + <> + <Dot position={pos1} opacity={1} /> + <Dot position={pos2} opacity={0.8} /> + <Dot position={pos3} opacity={0.6} /> + <Dot position={pos4} opacity={0.4} /> + <Dot position={pos5} opacity={0.2} /> + </> + ); +} + +function Dot({ position, opacity }) { + return ( + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function usePointerPosition() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + }, []); + return position; +} +``` + +```css +body { + min-height: 300px; +} +``` + +Note that this Effect _does not_ need cleanup. If you called `clearTimeout` in the cleanup function, then each time the `value` changes, it would reset the already scheduled timeout. To keep the movement continuous, you want all the timeouts to fire. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/scaling-up-with-reducer-and-context.md b/docs/src/learn/scaling-up-with-reducer-and-context.md new file mode 100644 index 000000000..805ac7f12 --- /dev/null +++ b/docs/src/learn/scaling-up-with-reducer-and-context.md @@ -0,0 +1,1369 @@ +## Overview + +<p class="intro" markdown> + +Reducers let you consolidate a component's state update logic. Context lets you pass information deep down to other components. You can combine reducers and context together to manage state of a complex screen. + +</p> + +!!! summary "You will learn" + + - How to combine a reducer with context + - How to avoid passing state and dispatch through props + - How to keep context and state logic in a separate file + +## Combining a reducer with context + +In this example from [the introduction to reducers](/learn/extracting-state-logic-into-a-reducer), the state is managed by a reducer. The reducer function contains all of the state update logic and is declared at the bottom of this file: + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Day off in Kyoto</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +A reducer helps keep the event handlers short and concise. However, as your app grows, you might run into another difficulty. **Currently, the `tasks` state and the `dispatch` function are only available in the top-level `TaskApp` component.** To let other components read the list of tasks or change it, you have to explicitly [pass down](/learn/passing-props-to-a-component) the current state and the event handlers that change it as props. + +For example, `TaskApp` passes a list of tasks and the event handlers to `TaskList`: + +```js +<TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} +/> +``` + +And `TaskList` passes the event handlers to `Task`: + +```js +<Task task={task} onChange={onChangeTask} onDelete={onDeleteTask} /> +``` + +In a small example like this, this works well, but if you have tens or hundreds of components in the middle, passing down all state and functions can be quite frustrating! + +This is why, as an alternative to passing them through props, you might want to put both the `tasks` state and the `dispatch` function [into context.](/learn/passing-data-deeply-with-context) **This way, any component below `TaskApp` in the tree can read the tasks and dispatch actions without the repetitive "prop drilling".** + +Here is how you can combine a reducer with context: + +1. **Create** the context. +2. **Put** state and dispatch into context. +3. **Use** context anywhere in the tree. + +### Step 1: Create the context + +The `useReducer` Hook returns the current `tasks` and the `dispatch` function that lets you update them: + +```js +const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); +``` + +To pass them down the tree, you will [create](/learn/passing-data-deeply-with-context#step-2-use-the-context) two separate contexts: + +- `TasksContext` provides the current list of tasks. +- `TasksDispatchContext` provides the function that lets components dispatch actions. + +Export them from a separate file so that you can later import them from other files: + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Day off in Kyoto</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { createContext } from "react"; + +export const TasksContext = createContext(null); +export const TasksDispatchContext = createContext(null); +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +Here, you're passing `null` as the default value to both contexts. The actual values will be provided by the `TaskApp` component. + +### Step 2: Put state and dispatch into context + +Now you can import both contexts in your `TaskApp` component. Take the `tasks` and `dispatch` returned by `useReducer()` and [provide them](/learn/passing-data-deeply-with-context#step-3-provide-the-context) to the entire tree below: + +```js +import { TasksContext, TasksDispatchContext } from "./TasksContext.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + // ... + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + ... + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} +``` + +For now, you pass the information both via props and in context: + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; +import { TasksContext, TasksDispatchContext } from "./TasksContext.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + <h1>Day off in Kyoto</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { createContext } from "react"; + +export const TasksContext = createContext(null); +export const TasksDispatchContext = createContext(null); +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button on_click={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +In the next step, you will remove prop passing. + +### Step 3: Use context anywhere in the tree + +Now you don't need to pass the list of tasks or the event handlers down the tree: + +```js +<TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + <h1>Day off in Kyoto</h1> + <AddTask /> + <TaskList /> + </TasksDispatchContext.Provider> +</TasksContext.Provider> +``` + +Instead, any component that needs the task list can read it from the `TaskContext`: + +```js +export default function TaskList() { + const tasks = useContext(TasksContext); + // ... +``` + +To update the task list, any component can read the `dispatch` function from context and call it: + +```js +export default function AddTask() { + const [text, setText] = useState(''); + const dispatch = useContext(TasksDispatchContext); + // ... + return ( + // ... + <button on_click={() => { + setText(''); + dispatch({ + type: 'added', + id: nextId++, + text: text, + }); + }}>Add</button> + // ... +``` + +**The `TaskApp` component does not pass any event handlers down, and the `TaskList` does not pass any event handlers to the `Task` component either.** Each component reads the context that it needs: + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; +import { TasksContext, TasksDispatchContext } from "./TasksContext.js"; + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + <h1>Day off in Kyoto</h1> + <AddTask /> + <TaskList /> + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { createContext } from "react"; + +export const TasksContext = createContext(null); +export const TasksDispatchContext = createContext(null); +``` + +```js +import { useState, useContext } from "react"; +import { TasksDispatchContext } from "./TasksContext.js"; + +export default function AddTask() { + const [text, setText] = useState(""); + const dispatch = useContext(TasksDispatchContext); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + }} + > + Add + </button> + </> + ); +} + +let nextId = 3; +``` + +```js +import { useState, useContext } from "react"; +import { TasksContext, TasksDispatchContext } from "./TasksContext.js"; + +export default function TaskList() { + const tasks = useContext(TasksContext); + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task task={task} /> + </li> + ))} + </ul> + ); +} + +function Task({ task }) { + const [isEditing, setIsEditing] = useState(false); + const dispatch = useContext(TasksDispatchContext); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + text: e.target.value, + }, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + done: e.target.checked, + }, + }); + }} + /> + {taskContent} + <button + on_click={() => { + dispatch({ + type: "deleted", + id: task.id, + }); + }} + > + Delete + </button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +**The state still "lives" in the top-level `TaskApp` component, managed with `useReducer`.** But its `tasks` and `dispatch` are now available to every component below in the tree by importing and using these contexts. + +## Moving all wiring into a single file + +You don't have to do this, but you could further declutter the components by moving both reducer and context into a single file. Currently, `TasksContext.js` contains only two context declarations: + +```js +import { createContext } from "react"; + +export const TasksContext = createContext(null); +export const TasksDispatchContext = createContext(null); +``` + +This file is about to get crowded! You'll move the reducer into that same file. Then you'll declare a new `TasksProvider` component in the same file. This component will tie all the pieces together: + +1. It will manage the state with a reducer. +2. It will provide both contexts to components below. +3. It will [take `children` as a prop](/learn/passing-props-to-a-component#passing-jsx-as-children) so you can pass JSX to it. + +```js +export function TasksProvider({ children }) { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + {children} + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} +``` + +**This removes all the complexity and wiring from your `TaskApp` component:** + +```js +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; +import { TasksProvider } from "./TasksContext.js"; + +export default function TaskApp() { + return ( + <TasksProvider> + <h1>Day off in Kyoto</h1> + <AddTask /> + <TaskList /> + </TasksProvider> + ); +} +``` + +```js +import { createContext, useReducer } from "react"; + +export const TasksContext = createContext(null); +export const TasksDispatchContext = createContext(null); + +export function TasksProvider({ children }) { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + {children} + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { useState, useContext } from "react"; +import { TasksDispatchContext } from "./TasksContext.js"; + +export default function AddTask() { + const [text, setText] = useState(""); + const dispatch = useContext(TasksDispatchContext); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + }} + > + Add + </button> + </> + ); +} + +let nextId = 3; +``` + +```js +import { useState, useContext } from "react"; +import { TasksContext, TasksDispatchContext } from "./TasksContext.js"; + +export default function TaskList() { + const tasks = useContext(TasksContext); + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task task={task} /> + </li> + ))} + </ul> + ); +} + +function Task({ task }) { + const [isEditing, setIsEditing] = useState(false); + const dispatch = useContext(TasksDispatchContext); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + text: e.target.value, + }, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + done: e.target.checked, + }, + }); + }} + /> + {taskContent} + <button + on_click={() => { + dispatch({ + type: "deleted", + id: task.id, + }); + }} + > + Delete + </button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +You can also export functions that _use_ the context from `TasksContext.js`: + +```js +export function useTasks() { + return useContext(TasksContext); +} + +export function useTasksDispatch() { + return useContext(TasksDispatchContext); +} +``` + +When a component needs to read context, it can do it through these functions: + +```js +const tasks = useTasks(); +const dispatch = useTasksDispatch(); +``` + +This doesn't change the behavior in any way, but it lets you later split these contexts further or add some logic to these functions. **Now all of the context and reducer wiring is in `TasksContext.js`. This keeps the components clean and uncluttered, focused on what they display rather than where they get the data:** + +```js +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; +import { TasksProvider } from "./TasksContext.js"; + +export default function TaskApp() { + return ( + <TasksProvider> + <h1>Day off in Kyoto</h1> + <AddTask /> + <TaskList /> + </TasksProvider> + ); +} +``` + +```js +import { createContext, useContext, useReducer } from "react"; + +const TasksContext = createContext(null); + +const TasksDispatchContext = createContext(null); + +export function TasksProvider({ children }) { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + {children} + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} + +export function useTasks() { + return useContext(TasksContext); +} + +export function useTasksDispatch() { + return useContext(TasksDispatchContext); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { useState } from "react"; +import { useTasksDispatch } from "./TasksContext.js"; + +export default function AddTask() { + const [text, setText] = useState(""); + const dispatch = useTasksDispatch(); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + on_click={() => { + setText(""); + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + }} + > + Add + </button> + </> + ); +} + +let nextId = 3; +``` + +```js +import { useState } from "react"; +import { useTasks, useTasksDispatch } from "./TasksContext.js"; + +export default function TaskList() { + const tasks = useTasks(); + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task task={task} /> + </li> + ))} + </ul> + ); +} + +function Task({ task }) { + const [isEditing, setIsEditing] = useState(false); + const dispatch = useTasksDispatch(); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + text: e.target.value, + }, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + done: e.target.checked, + }, + }); + }} + /> + {taskContent} + <button + on_click={() => { + dispatch({ + type: "deleted", + id: task.id, + }); + }} + > + Delete + </button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +You can think of `TasksProvider` as a part of the screen that knows how to deal with tasks, `useTasks` as a way to read them, and `useTasksDispatch` as a way to update them from any component below in the tree. + +<Note> + +Functions like `useTasks` and `useTasksDispatch` are called _[Custom Hooks.](/learn/reusing-logic-with-custom-hooks)_ Your function is considered a custom Hook if its name starts with `use`. This lets you use other Hooks, like `useContext`, inside it. + +</Note> + +As your app grows, you may have many context-reducer pairs like this. This is a powerful way to scale your app and [lift state up](/learn/sharing-state-between-components) without too much work whenever you want to access the data deep in the tree. + +<Recap> + +- You can combine reducer with context to let any component read and update state above it. +- To provide state and the dispatch function to components below: + 1. Create two contexts (for state and for dispatch functions). + 2. Provide both contexts from the component that uses the reducer. + 3. Use either context from components that need to read them. +- You can further declutter the components by moving all wiring into one file. + - You can export a component like `TasksProvider` that provides context. + - You can also export custom Hooks like `useTasks` and `useTasksDispatch` to read it. +- You can have many context-reducer pairs like this in your app. + +</Recap> diff --git a/docs/src/learn/separating-events-from-effects.md b/docs/src/learn/separating-events-from-effects.md new file mode 100644 index 000000000..c17fd5dbb --- /dev/null +++ b/docs/src/learn/separating-events-from-effects.md @@ -0,0 +1,1884 @@ +## Overview + +<p class="intro" markdown> + +Event handlers only re-run when you perform the same interaction again. Unlike event handlers, Effects re-synchronize if some value they read, like a prop or a state variable, is different from what it was during the last render. Sometimes, you also want a mix of both behaviors: an Effect that re-runs in response to some values but not others. This page will teach you how to do that. + +</p> + +!!! summary "You will learn" + + - How to choose between an event handler and an Effect + - Why Effects are reactive, and event handlers are not + - What to do when you want a part of your Effect's code to not be reactive + - What Effect Events are, and how to extract them from your Effects + - How to read the latest props and state from Effects using Effect Events + +## Choosing between event handlers and Effects + +First, let's recap the difference between event handlers and Effects. + +Imagine you're implementing a chat room component. Your requirements look like this: + +1. Your component should automatically connect to the selected chat room. +1. When you click the "Send" button, it should send a message to the chat. + +Let's say you've already implemented the code for them, but you're not sure where to put it. Should you use event handlers or Effects? Every time you need to answer this question, consider [_why_ the code needs to run.](/learn/synchronizing-with-effects#what-are-effects-and-how-are-they-different-from-events) + +### Event handlers run in response to specific interactions + +From the user's perspective, sending a message should happen _because_ the particular "Send" button was clicked. The user will get rather upset if you send their message at any other time or for any other reason. This is why sending a message should be an event handler. Event handlers let you handle specific interactions: + +```js +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + // ... + function handleSendClick() { + sendMessage(message); + } + // ... + return ( + <> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button on_click={handleSendClick}>Send</button>; + </> + ); +} +``` + +With an event handler, you can be sure that `sendMessage(message)` will _only_ run if the user presses the button. + +### Effects run whenever synchronization is needed + +Recall that you also need to keep the component connected to the chat room. Where does that code go? + +The _reason_ to run this code is not some particular interaction. It doesn't matter why or how the user navigated to the chat room screen. Now that they're looking at it and could interact with it, the component needs to stay connected to the selected chat server. Even if the chat room component was the initial screen of your app, and the user has not performed any interactions at all, you would _still_ need to connect. This is why it's an Effect: + +```js +function ChatRoom({ roomId }) { + // ... + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +With this code, you can be sure that there is always an active connection to the currently selected chat server, _regardless_ of the specific interactions performed by the user. Whether the user has only opened your app, selected a different room, or navigated to another screen and back, your Effect ensures that the component will _remain synchronized_ with the currently selected room, and will [re-connect whenever it's necessary.](/learn/lifecycle-of-reactive-effects#why-synchronization-may-need-to-happen-more-than-once) + +```js +import { useState, useEffect } from "react"; +import { createConnection, sendMessage } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + function handleSendClick() { + sendMessage(message); + } + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button on_click={handleSendClick}>Send</button> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [show, setShow] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <button on_click={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function sendMessage(message) { + console.log("🔵 You sent: " + message); +} + +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input, +select { + margin-right: 20px; +} +``` + +## Reactive values and reactive logic + +Intuitively, you could say that event handlers are always triggered "manually", for example by clicking a button. Effects, on the other hand, are "automatic": they run and re-run as often as it's needed to stay synchronized. + +There is a more precise way to think about this. + +Props, state, and variables declared inside your component's body are called <CodeStep step={2}>reactive values</CodeStep>. In this example, `serverUrl` is not a reactive value, but `roomId` and `message` are. They participate in the rendering data flow: + +```js +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + // ... +} +``` + +Reactive values like these can change due to a re-render. For example, the user may edit the `message` or choose a different `roomId` in a dropdown. Event handlers and Effects respond to changes differently: + +- **Logic inside event handlers is _not reactive._** It will not run again unless the user performs the same interaction (e.g. a click) again. Event handlers can read reactive values without "reacting" to their changes. +- **Logic inside Effects is _reactive._** If your Effect reads a reactive value, [you have to specify it as a dependency.](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) Then, if a re-render causes that value to change, React will re-run your Effect's logic with the new value. + +Let's revisit the previous example to illustrate this difference. + +### Logic inside event handlers is not reactive + +Take a look at this line of code. Should this logic be reactive or not? + +```js +// ... +sendMessage(message); +// ... +``` + +From the user's perspective, **a change to the `message` does _not_ mean that they want to send a message.** It only means that the user is typing. In other words, the logic that sends a message should not be reactive. It should not run again only because the <CodeStep step={2}>reactive value</CodeStep> has changed. That's why it belongs in the event handler: + +```js +function handleSendClick() { + sendMessage(message); +} +``` + +Event handlers aren't reactive, so `sendMessage(message)` will only run when the user clicks the Send button. + +### Logic inside Effects is reactive + +Now let's return to these lines: + +```js +// ... +const connection = createConnection(serverUrl, roomId); +connection.connect(); +// ... +``` + +From the user's perspective, **a change to the `roomId` _does_ mean that they want to connect to a different room.** In other words, the logic for connecting to the room should be reactive. You _want_ these lines of code to "keep up" with the <CodeStep step={2}>reactive value</CodeStep>, and to run again if that value is different. That's why it belongs in an Effect: + +```js +useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; +}, [roomId]); +``` + +Effects are reactive, so `createConnection(serverUrl, roomId)` and `connection.connect()` will run for every distinct value of `roomId`. Your Effect keeps the chat connection synchronized to the currently selected room. + +## Extracting non-reactive logic out of Effects + +Things get more tricky when you want to mix reactive logic with non-reactive logic. + +For example, imagine that you want to show a notification when the user connects to the chat. You read the current theme (dark or light) from the props so that you can show the notification in the correct color: + +```js +function ChatRoom({ roomId, theme }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on('connected', () => { + showNotification('Connected!', theme); + }); + connection.connect(); + // ... +``` + +However, `theme` is a reactive value (it can change as a result of re-rendering), and [every reactive value read by an Effect must be declared as its dependency.](/learn/lifecycle-of-reactive-effects#react-verifies-that-you-specified-every-reactive-value-as-a-dependency) Now you have to specify `theme` as a dependency of your Effect: + +```js +function ChatRoom({ roomId, theme }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on('connected', () => { + showNotification('Connected!', theme); + }); + connection.connect(); + return () => { + connection.disconnect() + }; + }, [roomId, theme]); // ✅ All dependencies declared + // ... +``` + +Play with this example and see if you can spot the problem with this user experience: + +```json +{ + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { createConnection, sendMessage } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId, theme }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on("connected", () => { + showNotification("Connected!", theme); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, theme]); + + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <hr /> + <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "connected") { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} +``` + +When the `roomId` changes, the chat re-connects as you would expect. But since `theme` is also a dependency, the chat _also_ re-connects every time you switch between the dark and the light theme. That's not great! + +In other words, you _don't_ want this line to be reactive, even though it is inside an Effect (which is reactive): + +```js +// ... +showNotification("Connected!", theme); +// ... +``` + +You need a way to separate this non-reactive logic from the reactive Effect around it. + +### Declaring an Effect Event + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +Use a special Hook called [`useEffectEvent`](/reference/react/experimental_useEffectEvent) to extract this non-reactive logic out of your Effect: + +```js +import { useEffect, useEffectEvent } from 'react'; + +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent(() => { + showNotification('Connected!', theme); + }); + // ... +``` + +Here, `onConnected` is called an _Effect Event._ It's a part of your Effect logic, but it behaves a lot more like an event handler. The logic inside it is not reactive, and it always "sees" the latest values of your props and state. + +Now you can call the `onConnected` Effect Event from inside your Effect: + +```js +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent(() => { + showNotification('Connected!', theme); + }); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on('connected', () => { + onConnected(); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +``` + +This solves the problem. Note that you had to _remove_ `onConnected` from the list of your Effect's dependencies. **Effect Events are not reactive and must be omitted from dependencies.** + +Verify that the new behavior works as you would expect: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { createConnection, sendMessage } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent(() => { + showNotification("Connected!", theme); + }); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on("connected", () => { + onConnected(); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <hr /> + <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "connected") { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} +``` + +You can think of Effect Events as being very similar to event handlers. The main difference is that event handlers run in response to a user interactions, whereas Effect Events are triggered by you from Effects. Effect Events let you "break the chain" between the reactivity of Effects and code that should not be reactive. + +### Reading latest props and state with Effect Events + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +Effect Events let you fix many patterns where you might be tempted to suppress the dependency linter. + +For example, say you have an Effect to log the page visits: + +```js +function Page() { + useEffect(() => { + logVisit(); + }, []); + // ... +} +``` + +Later, you add multiple routes to your site. Now your `Page` component receives a `url` prop with the current path. You want to pass the `url` as a part of your `logVisit` call, but the dependency linter complains: + +```js +function Page({ url }) { + useEffect(() => { + logVisit(url); + }, []); // 🔴 React Hook useEffect has a missing dependency: 'url' + // ... +} +``` + +Think about what you want the code to do. You _want_ to log a separate visit for different URLs since each URL represents a different page. In other words, this `logVisit` call _should_ be reactive with respect to the `url`. This is why, in this case, it makes sense to follow the dependency linter, and add `url` as a dependency: + +```js +function Page({ url }) { + useEffect(() => { + logVisit(url); + }, [url]); // ✅ All dependencies declared + // ... +} +``` + +Now let's say you want to include the number of items in the shopping cart together with every page visit: + +```js +function Page({ url }) { + const { items } = useContext(ShoppingCartContext); + const numberOfItems = items.length; + + useEffect(() => { + logVisit(url, numberOfItems); + }, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems' + // ... +} +``` + +You used `numberOfItems` inside the Effect, so the linter asks you to add it as a dependency. However, you _don't_ want the `logVisit` call to be reactive with respect to `numberOfItems`. If the user puts something into the shopping cart, and the `numberOfItems` changes, this _does not mean_ that the user visited the page again. In other words, _visiting the page_ is, in some sense, an "event". It happens at a precise moment in time. + +Split the code in two parts: + +```js +function Page({ url }) { + const { items } = useContext(ShoppingCartContext); + const numberOfItems = items.length; + + const onVisit = useEffectEvent((visitedUrl) => { + logVisit(visitedUrl, numberOfItems); + }); + + useEffect(() => { + onVisit(url); + }, [url]); // ✅ All dependencies declared + // ... +} +``` + +Here, `onVisit` is an Effect Event. The code inside it isn't reactive. This is why you can use `numberOfItems` (or any other reactive value!) without worrying that it will cause the surrounding code to re-execute on changes. + +On the other hand, the Effect itself remains reactive. Code inside the Effect uses the `url` prop, so the Effect will re-run after every re-render with a different `url`. This, in turn, will call the `onVisit` Effect Event. + +As a result, you will call `logVisit` for every change to the `url`, and always read the latest `numberOfItems`. However, if `numberOfItems` changes on its own, this will not cause any of the code to re-run. + +<Note> + +You might be wondering if you could call `onVisit()` with no arguments, and read the `url` inside it: + +```js +const onVisit = useEffectEvent(() => { + logVisit(url, numberOfItems); +}); + +useEffect(() => { + onVisit(); +}, [url]); +``` + +This would work, but it's better to pass this `url` to the Effect Event explicitly. **By passing `url` as an argument to your Effect Event, you are saying that visiting a page with a different `url` constitutes a separate "event" from the user's perspective.** The `visitedUrl` is a _part_ of the "event" that happened: + +```js +const onVisit = useEffectEvent((visitedUrl) => { + logVisit(visitedUrl, numberOfItems); +}); + +useEffect(() => { + onVisit(url); +}, [url]); +``` + +Since your Effect Event explicitly "asks" for the `visitedUrl`, now you can't accidentally remove `url` from the Effect's dependencies. If you remove the `url` dependency (causing distinct page visits to be counted as one), the linter will warn you about it. You want `onVisit` to be reactive with regards to the `url`, so instead of reading the `url` inside (where it wouldn't be reactive), you pass it _from_ your Effect. + +This becomes especially important if there is some asynchronous logic inside the Effect: + +```js +const onVisit = useEffectEvent((visitedUrl) => { + logVisit(visitedUrl, numberOfItems); +}); + +useEffect(() => { + setTimeout(() => { + onVisit(url); + }, 5000); // Delay logging visits +}, [url]); +``` + +Here, `url` inside `onVisit` corresponds to the _latest_ `url` (which could have already changed), but `visitedUrl` corresponds to the `url` that originally caused this Effect (and this `onVisit` call) to run. + +</Note> + +<DeepDive> + +#### Is it okay to suppress the dependency linter instead? + +In the existing codebases, you may sometimes see the lint rule suppressed like this: + +```js +function Page({ url }) { + const { items } = useContext(ShoppingCartContext); + const numberOfItems = items.length; + + useEffect(() => { + logVisit(url, numberOfItems); + // 🔴 Avoid suppressing the linter like this: + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url]); + // ... +} +``` + +After `useEffectEvent` becomes a stable part of React, we recommend **never suppressing the linter**. + +The first downside of suppressing the rule is that React will no longer warn you when your Effect needs to "react" to a new reactive dependency you've introduced to your code. In the earlier example, you added `url` to the dependencies _because_ React reminded you to do it. You will no longer get such reminders for any future edits to that Effect if you disable the linter. This leads to bugs. + +Here is an example of a confusing bug caused by suppressing the linter. In this example, the `handleMove` function is supposed to read the current `canMove` state variable value in order to decide whether the dot should follow the cursor. However, `canMove` is always `true` inside `handleMove`. + +Can you see why? + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + useEffect(() => { + window.addEventListener("pointermove", handleMove); + return () => window.removeEventListener("pointermove", handleMove); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +The problem with this code is in suppressing the dependency linter. If you remove the suppression, you'll see that this Effect should depend on the `handleMove` function. This makes sense: `handleMove` is declared inside the component body, which makes it a reactive value. Every reactive value must be specified as a dependency, or it can potentially get stale over time! + +The author of the original code has "lied" to React by saying that the Effect does not depend (`[]`) on any reactive values. This is why React did not re-synchronize the Effect after `canMove` has changed (and `handleMove` with it). Because React did not re-synchronize the Effect, the `handleMove` attached as a listener is the `handleMove` function created during the initial render. During the initial render, `canMove` was `true`, which is why `handleMove` from the initial render will forever see that value. + +**If you never suppress the linter, you will never see problems with stale values.** + +With `useEffectEvent`, there is no need to "lie" to the linter, and the code works as you would expect: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + const onMove = useEffectEvent((e) => { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + }); + + useEffect(() => { + window.addEventListener("pointermove", onMove); + return () => window.removeEventListener("pointermove", onMove); + }, []); + + return ( + <> + <label> + <input + type="checkbox" + checked={canMove} + onChange={(e) => setCanMove(e.target.checked)} + /> + The dot is allowed to move + </label> + <hr /> + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + </> + ); +} +``` + +```css +body { + height: 200px; +} +``` + +This doesn't mean that `useEffectEvent` is _always_ the correct solution. You should only apply it to the lines of code that you don't want to be reactive. In the above sandbox, you didn't want the Effect's code to be reactive with regards to `canMove`. That's why it made sense to extract an Effect Event. + +Read [Removing Effect Dependencies](/learn/removing-effect-dependencies) for other correct alternatives to suppressing the linter. + +</DeepDive> + +### Limitations of Effect Events + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +Effect Events are very limited in how you can use them: + +- **Only call them from inside Effects.** +- **Never pass them to other components or Hooks.** + +For example, don't declare and pass an Effect Event like this: + +```js +function Timer() { + const [count, setCount] = useState(0); + + const onTick = useEffectEvent(() => { + setCount(count + 1); + }); + + useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events + + return <h1>{count}</h1>; +} + +function useTimer(callback, delay) { + useEffect(() => { + const id = setInterval(() => { + callback(); + }, delay); + return () => { + clearInterval(id); + }; + }, [delay, callback]); // Need to specify "callback" in dependencies +} +``` + +Instead, always declare Effect Events directly next to the Effects that use them: + +```js +function Timer() { + const [count, setCount] = useState(0); + useTimer(() => { + setCount(count + 1); + }, 1000); + return <h1>{count}</h1>; +} + +function useTimer(callback, delay) { + const onTick = useEffectEvent(() => { + callback(); + }); + + useEffect(() => { + const id = setInterval(() => { + onTick(); // ✅ Good: Only called locally inside an Effect + }, delay); + return () => { + clearInterval(id); + }; + }, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency +} +``` + +Effect Events are non-reactive "pieces" of your Effect code. They should be next to the Effect using them. + +<Recap> + +- Event handlers run in response to specific interactions. +- Effects run whenever synchronization is needed. +- Logic inside event handlers is not reactive. +- Logic inside Effects is reactive. +- You can move non-reactive logic from Effects into Effect Events. +- Only call Effect Events from inside Effects. +- Don't pass Effect Events to other components or Hooks. + +</Recap> + +<Challenges> + +#### Fix a variable that doesn't update + +This `Timer` component keeps a `count` state variable which increases every second. The value by which it's increasing is stored in the `increment` state variable. You can control the `increment` variable with the plus and minus buttons. + +However, no matter how many times you click the plus button, the counter is still incremented by one every second. What's wrong with this code? Why is `increment` always equal to `1` inside the Effect's code? Find the mistake and fix it. + +<Hint> + +To fix this code, it's enough to follow the rules. + +</Hint> + +```js +import { useState, useEffect } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + increment); + }, 1000); + return () => { + clearInterval(id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Every second, increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +<Solution> + +As usual, when you're looking for bugs in Effects, start by searching for linter suppressions. + +If you remove the suppression comment, React will tell you that this Effect's code depends on `increment`, but you "lied" to React by claiming that this Effect does not depend on any reactive values (`[]`). Add `increment` to the dependency array: + +```js +import { useState, useEffect } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + increment); + }, 1000); + return () => { + clearInterval(id); + }; + }, [increment]); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Every second, increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +Now, when `increment` changes, React will re-synchronize your Effect, which will restart the interval. + +</Solution> + +#### Fix a freezing counter + +This `Timer` component keeps a `count` state variable which increases every second. The value by which it's increasing is stored in the `increment` state variable, which you can control it with the plus and minus buttons. For example, try pressing the plus button nine times, and notice that the `count` now increases each second by ten rather than by one. + +There is a small issue with this user interface. You might notice that if you keep pressing the plus or minus buttons faster than once per second, the timer itself seems to pause. It only resumes after a second passes since the last time you've pressed either button. Find why this is happening, and fix the issue so that the timer ticks on _every_ second without interruptions. + +<Hint> + +It seems like the Effect which sets up the timer "reacts" to the `increment` value. Does the line that uses the current `increment` value in order to call `setCount` really need to be reactive? + +</Hint> + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + + useEffect(() => { + const id = setInterval(() => { + setCount((c) => c + increment); + }, 1000); + return () => { + clearInterval(id); + }; + }, [increment]); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Every second, increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +<Solution> + +The issue is that the code inside the Effect uses the `increment` state variable. Since it's a dependency of your Effect, every change to `increment` causes the Effect to re-synchronize, which causes the interval to clear. If you keep clearing the interval every time before it has a chance to fire, it will appear as if the timer has stalled. + +To solve the issue, extract an `onTick` Effect Event from the Effect: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + + const onTick = useEffectEvent(() => { + setCount((c) => c + increment); + }); + + useEffect(() => { + const id = setInterval(() => { + onTick(); + }, 1000); + return () => { + clearInterval(id); + }; + }, []); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Every second, increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +Since `onTick` is an Effect Event, the code inside it isn't reactive. The change to `increment` does not trigger any Effects. + +</Solution> + +#### Fix a non-adjustable delay + +In this example, you can customize the interval delay. It's stored in a `delay` state variable which is updated by two buttons. However, even if you press the "plus 100 ms" button until the `delay` is 1000 milliseconds (that is, a second), you'll notice that the timer still increments very fast (every 100 ms). It's as if your changes to the `delay` are ignored. Find and fix the bug. + +<Hint> + +Code inside Effect Events is not reactive. Are there cases in which you would _want_ the `setInterval` call to re-run? + +</Hint> + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + const [delay, setDelay] = useState(100); + + const onTick = useEffectEvent(() => { + setCount((c) => c + increment); + }); + + const onMount = useEffectEvent(() => { + return setInterval(() => { + onTick(); + }, delay); + }); + + useEffect(() => { + const id = onMount(); + return () => { + clearInterval(id); + }; + }, []); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + <p> + Increment delay: + <button + disabled={delay === 100} + on_click={() => { + setDelay((d) => d - 100); + }} + > + –100 ms + </button> + <b>{delay} ms</b> + <button + on_click={() => { + setDelay((d) => d + 100); + }} + > + +100 ms + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +<Solution> + +The problem with the above example is that it extracted an Effect Event called `onMount` without considering what the code should actually be doing. You should only extract Effect Events for a specific reason: when you want to make a part of your code non-reactive. However, the `setInterval` call _should_ be reactive with respect to the `delay` state variable. If the `delay` changes, you want to set up the interval from scratch! To fix this code, pull all the reactive code back inside the Effect: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; + +export default function Timer() { + const [count, setCount] = useState(0); + const [increment, setIncrement] = useState(1); + const [delay, setDelay] = useState(100); + + const onTick = useEffectEvent(() => { + setCount((c) => c + increment); + }); + + useEffect(() => { + const id = setInterval(() => { + onTick(); + }, delay); + return () => { + clearInterval(id); + }; + }, [delay]); + + return ( + <> + <h1> + Counter: {count} + <button on_click={() => setCount(0)}>Reset</button> + </h1> + <hr /> + <p> + Increment by: + <button + disabled={increment === 0} + on_click={() => { + setIncrement((i) => i - 1); + }} + > + – + </button> + <b>{increment}</b> + <button + on_click={() => { + setIncrement((i) => i + 1); + }} + > + + + </button> + </p> + <p> + Increment delay: + <button + disabled={delay === 100} + on_click={() => { + setDelay((d) => d - 100); + }} + > + –100 ms + </button> + <b>{delay} ms</b> + <button + on_click={() => { + setDelay((d) => d + 100); + }} + > + +100 ms + </button> + </p> + </> + ); +} +``` + +```css +button { + margin: 10px; +} +``` + +In general, you should be suspicious of functions like `onMount` that focus on the _timing_ rather than the _purpose_ of a piece of code. It may feel "more descriptive" at first but it obscures your intent. As a rule of thumb, Effect Events should correspond to something that happens from the _user's_ perspective. For example, `onMessage`, `onTick`, `onVisit`, or `onConnected` are good Effect Event names. Code inside them would likely not need to be reactive. On the other hand, `onMount`, `onUpdate`, `onUnmount`, or `onAfterRender` are so generic that it's easy to accidentally put code that _should_ be reactive into them. This is why you should name your Effect Events after _what the user thinks has happened,_ not when some code happened to run. + +</Solution> + +#### Fix a delayed notification + +When you join a chat room, this component shows a notification. However, it doesn't show the notification immediately. Instead, the notification is artificially delayed by two seconds so that the user has a chance to look around the UI. + +This almost works, but there is a bug. Try changing the dropdown from "general" to "travel" and then to "music" very quickly. If you do it fast enough, you will see two notifications (as expected!) but they will _both_ say "Welcome to music". + +Fix it so that when you switch from "general" to "travel" and then to "music" very quickly, you see two notifications, the first one being "Welcome to travel" and the second one being "Welcome to music". (For an additional challenge, assuming you've _already_ made the notifications show the correct rooms, change the code so that only the latter notification is displayed.) + +<Hint> + +Your Effect knows which room it connected to. Is there any information that you might want to pass to your Effect Event? + +</Hint> + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { createConnection, sendMessage } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent(() => { + showNotification("Welcome to " + roomId, theme); + }); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on("connected", () => { + setTimeout(() => { + onConnected(); + }, 2000); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <hr /> + <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "connected") { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} +``` + +<Solution> + +Inside your Effect Event, `roomId` is the value _at the time Effect Event was called._ + +Your Effect Event is called with a two second delay. If you're quickly switching from the travel to the music room, by the time the travel room's notification shows, `roomId` is already `"music"`. This is why both notifications say "Welcome to music". + +To fix the issue, instead of reading the _latest_ `roomId` inside the Effect Event, make it a parameter of your Effect Event, like `connectedRoomId` below. Then pass `roomId` from your Effect by calling `onConnected(roomId)`: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { createConnection, sendMessage } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent((connectedRoomId) => { + showNotification("Welcome to " + connectedRoomId, theme); + }); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.on("connected", () => { + setTimeout(() => { + onConnected(roomId); + }, 2000); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <hr /> + <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "connected") { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} +``` + +The Effect that had `roomId` set to `"travel"` (so it connected to the `"travel"` room) will show the notification for `"travel"`. The Effect that had `roomId` set to `"music"` (so it connected to the `"music"` room) will show the notification for `"music"`. In other words, `connectedRoomId` comes from your Effect (which is reactive), while `theme` always uses the latest value. + +To solve the additional challenge, save the notification timeout ID and clear it in the cleanup function of your Effect: + +```json +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState, useEffect } from "react"; +import { experimental_useEffectEvent as useEffectEvent } from "react"; +import { createConnection, sendMessage } from "./chat.js"; +import { showNotification } from "./notifications.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId, theme }) { + const onConnected = useEffectEvent((connectedRoomId) => { + showNotification("Welcome to " + connectedRoomId, theme); + }); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + let notificationTimeoutId; + connection.on("connected", () => { + notificationTimeoutId = setTimeout(() => { + onConnected(roomId); + }, 2000); + }); + connection.connect(); + return () => { + connection.disconnect(); + if (notificationTimeoutId !== undefined) { + clearTimeout(notificationTimeoutId); + } + }; + }, [roomId]); + + return <h1>Welcome to the {roomId} room!</h1>; +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Use dark theme + </label> + <hr /> + <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error("Cannot add the handler twice."); + } + if (event !== "connected") { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; +} +``` + +```js +import Toastify from "toastify-js"; +import "toastify-js/src/toastify.css"; + +export function showNotification(message, theme) { + Toastify({ + text: message, + duration: 2000, + gravity: "top", + position: "right", + style: { + background: theme === "dark" ? "black" : "white", + color: theme === "dark" ? "white" : "black", + }, + }).showToast(); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} +``` + +This ensures that already scheduled (but not yet displayed) notifications get cancelled when you change rooms. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/sharing-state-between-components.md b/docs/src/learn/sharing-state-between-components.md new file mode 100644 index 000000000..c72320935 --- /dev/null +++ b/docs/src/learn/sharing-state-between-components.md @@ -0,0 +1,575 @@ +## Overview + +<p class="intro" markdown> + +Sometimes, you want the state of two components to always change together. To do it, remove state from both of them, move it to their closest common parent, and then pass it down to them via props. This is known as _lifting state up,_ and it's one of the most common things you will do writing React code. + +</p> + +!!! summary "You will learn" + + - How to share state between components by lifting it up + - What are controlled and uncontrolled components + +## Lifting state up by example + +In this example, a parent `Accordion` component renders two separate `Panel`s: + +- `Accordion` + - `Panel` + - `Panel` + +Each `Panel` component has a boolean `isActive` state that determines whether its content is visible. + +Press the Show button for both panels: + +```js +import { useState } from "react"; + +function Panel({ title, children }) { + const [isActive, setIsActive] = useState(false); + return ( + <section className="panel"> + <h3>{title}</h3> + {isActive ? ( + <p>{children}</p> + ) : ( + <button on_click={() => setIsActive(true)}>Show</button> + )} + </section> + ); +} + +export default function Accordion() { + return ( + <> + <h2>Almaty, Kazakhstan</h2> + <Panel title="About"> + With a population of about 2 million, Almaty is Kazakhstan's + largest city. From 1929 to 1997, it was its capital city. + </Panel> + <Panel title="Etymology"> + The name comes from <span lang="kk-KZ">алма</span>, the Kazakh + word for "apple" and is often translated as "full of apples". In + fact, the region surrounding Almaty is thought to be the + ancestral home of the apple, and the wild{" "} + <i lang="la">Malus sieversii</i> is considered a likely + candidate for the ancestor of the modern domestic apple. + </Panel> + </> + ); +} +``` + +```css +h3, +p { + margin: 5px 0px; +} +.panel { + padding: 10px; + border: 1px solid #aaa; +} +``` + +Notice how pressing one panel's button does not affect the other panel--they are independent. + +<!-- TODO: Diagram --> + +**But now let's say you want to change it so that only one panel is expanded at any given time.** With that design, expanding the second panel should collapse the first one. How would you do that? + +To coordinate these two panels, you need to "lift their state up" to a parent component in three steps: + +1. **Remove** state from the child components. +2. **Pass** hardcoded data from the common parent. +3. **Add** state to the common parent and pass it down together with the event handlers. + +This will allow the `Accordion` component to coordinate both `Panel`s and only expand one at a time. + +### Step 1: Remove state from the child components + +You will give control of the `Panel`'s `isActive` to its parent component. This means that the parent component will pass `isActive` to `Panel` as a prop instead. Start by **removing this line** from the `Panel` component: + +```js +const [isActive, setIsActive] = useState(false); +``` + +And instead, add `isActive` to the `Panel`'s list of props: + +```js +function Panel({ title, children, isActive }) { +``` + +Now the `Panel`'s parent component can _control_ `isActive` by [passing it down as a prop.](/learn/passing-props-to-a-component) Conversely, the `Panel` component now has _no control_ over the value of `isActive`--it's now up to the parent component! + +### Step 2: Pass hardcoded data from the common parent + +To lift state up, you must locate the closest common parent component of _both_ of the child components that you want to coordinate: + +- `Accordion` _(closest common parent)_ + - `Panel` + - `Panel` + +In this example, it's the `Accordion` component. Since it's above both panels and can control their props, it will become the "source of truth" for which panel is currently active. Make the `Accordion` component pass a hardcoded value of `isActive` (for example, `true`) to both panels: + +```js +import { useState } from "react"; + +export default function Accordion() { + return ( + <> + <h2>Almaty, Kazakhstan</h2> + <Panel title="About" isActive={true}> + With a population of about 2 million, Almaty is Kazakhstan's + largest city. From 1929 to 1997, it was its capital city. + </Panel> + <Panel title="Etymology" isActive={true}> + The name comes from <span lang="kk-KZ">алма</span>, the Kazakh + word for "apple" and is often translated as "full of apples". In + fact, the region surrounding Almaty is thought to be the + ancestral home of the apple, and the wild{" "} + <i lang="la">Malus sieversii</i> is considered a likely + candidate for the ancestor of the modern domestic apple. + </Panel> + </> + ); +} + +function Panel({ title, children, isActive }) { + return ( + <section className="panel"> + <h3>{title}</h3> + {isActive ? ( + <p>{children}</p> + ) : ( + <button on_click={() => setIsActive(true)}>Show</button> + )} + </section> + ); +} +``` + +```css +h3, +p { + margin: 5px 0px; +} +.panel { + padding: 10px; + border: 1px solid #aaa; +} +``` + +Try editing the hardcoded `isActive` values in the `Accordion` component and see the result on the screen. + +### Step 3: Add state to the common parent + +Lifting state up often changes the nature of what you're storing as state. + +In this case, only one panel should be active at a time. This means that the `Accordion` common parent component needs to keep track of _which_ panel is the active one. Instead of a `boolean` value, it could use a number as the index of the active `Panel` for the state variable: + +```js +const [activeIndex, setActiveIndex] = useState(0); +``` + +When the `activeIndex` is `0`, the first panel is active, and when it's `1`, it's the second one. + +Clicking the "Show" button in either `Panel` needs to change the active index in `Accordion`. A `Panel` can't set the `activeIndex` state directly because it's defined inside the `Accordion`. The `Accordion` component needs to _explicitly allow_ the `Panel` component to change its state by [passing an event handler down as a prop](/learn/responding-to-events#passing-event-handlers-as-props): + +```js +<> + <Panel isActive={activeIndex === 0} onShow={() => setActiveIndex(0)}> + ... + </Panel> + <Panel isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}> + ... + </Panel> +</> +``` + +The `<button>` inside the `Panel` will now use the `onShow` prop as its click event handler: + +```js +import { useState } from "react"; + +export default function Accordion() { + const [activeIndex, setActiveIndex] = useState(0); + return ( + <> + <h2>Almaty, Kazakhstan</h2> + <Panel + title="About" + isActive={activeIndex === 0} + onShow={() => setActiveIndex(0)} + > + With a population of about 2 million, Almaty is Kazakhstan's + largest city. From 1929 to 1997, it was its capital city. + </Panel> + <Panel + title="Etymology" + isActive={activeIndex === 1} + onShow={() => setActiveIndex(1)} + > + The name comes from <span lang="kk-KZ">алма</span>, the Kazakh + word for "apple" and is often translated as "full of apples". In + fact, the region surrounding Almaty is thought to be the + ancestral home of the apple, and the wild{" "} + <i lang="la">Malus sieversii</i> is considered a likely + candidate for the ancestor of the modern domestic apple. + </Panel> + </> + ); +} + +function Panel({ title, children, isActive, onShow }) { + return ( + <section className="panel"> + <h3>{title}</h3> + {isActive ? ( + <p>{children}</p> + ) : ( + <button on_click={onShow}>Show</button> + )} + </section> + ); +} +``` + +```css +h3, +p { + margin: 5px 0px; +} +.panel { + padding: 10px; + border: 1px solid #aaa; +} +``` + +This completes lifting state up! Moving state into the common parent component allowed you to coordinate the two panels. Using the active index instead of two "is shown" flags ensured that only one panel is active at a given time. And passing down the event handler to the child allowed the child to change the parent's state. + +<!-- TODO: Diagram --> + +<DeepDive> + +#### Controlled and uncontrolled components + +It is common to call a component with some local state "uncontrolled". For example, the original `Panel` component with an `isActive` state variable is uncontrolled because its parent cannot influence whether the panel is active or not. + +In contrast, you might say a component is "controlled" when the important information in it is driven by props rather than its own local state. This lets the parent component fully specify its behavior. The final `Panel` component with the `isActive` prop is controlled by the `Accordion` component. + +Uncontrolled components are easier to use within their parents because they require less configuration. But they're less flexible when you want to coordinate them together. Controlled components are maximally flexible, but they require the parent components to fully configure them with props. + +In practice, "controlled" and "uncontrolled" aren't strict technical terms--each component usually has some mix of both local state and props. However, this is a useful way to talk about how components are designed and what capabilities they offer. + +When writing a component, consider which information in it should be controlled (via props), and which information should be uncontrolled (via state). But you can always change your mind and refactor later. + +</DeepDive> + +## A single source of truth for each state + +In a React application, many components will have their own state. Some state may "live" close to the leaf components (components at the bottom of the tree) like inputs. Other state may "live" closer to the top of the app. For example, even client-side routing libraries are usually implemented by storing the current route in the React state, and passing it down by props! + +**For each unique piece of state, you will choose the component that "owns" it.** This principle is also known as having a ["single source of truth".](https://en.wikipedia.org/wiki/Single_source_of_truth) It doesn't mean that all state lives in one place--but that for _each_ piece of state, there is a _specific_ component that holds that piece of information. Instead of duplicating shared state between components, _lift it up_ to their common shared parent, and _pass it down_ to the children that need it. + +Your app will change as you work on it. It is common that you will move state down or back up while you're still figuring out where each piece of the state "lives". This is all part of the process! + +To see what this feels like in practice with a few more components, read [Thinking in React.](/learn/thinking-in-react) + +<Recap> + +- When you want to coordinate two components, move their state to their common parent. +- Then pass the information down through props from their common parent. +- Finally, pass the event handlers down so that the children can change the parent's state. +- It's useful to consider components as "controlled" (driven by props) or "uncontrolled" (driven by state). + +</Recap> + +<Challenges> + +#### Synced inputs + +These two inputs are independent. Make them stay in sync: editing one input should update the other input with the same text, and vice versa. + +<Hint> + +You'll need to lift their state up into the parent component. + +</Hint> + +```js +import { useState } from "react"; + +export default function SyncedInputs() { + return ( + <> + <Input label="First input" /> + <Input label="Second input" /> + </> + ); +} + +function Input({ label }) { + const [text, setText] = useState(""); + + function handleChange(e) { + setText(e.target.value); + } + + return ( + <label> + {label} <input value={text} onChange={handleChange} /> + </label> + ); +} +``` + +```css +input { + margin: 5px; +} +label { + display: block; +} +``` + +<Solution> + +Move the `text` state variable into the parent component along with the `handleChange` handler. Then pass them down as props to both of the `Input` components. This will keep them in sync. + +```js +import { useState } from "react"; + +export default function SyncedInputs() { + const [text, setText] = useState(""); + + function handleChange(e) { + setText(e.target.value); + } + + return ( + <> + <Input label="First input" value={text} onChange={handleChange} /> + <Input label="Second input" value={text} onChange={handleChange} /> + </> + ); +} + +function Input({ label, value, onChange }) { + return ( + <label> + {label} <input value={value} onChange={onChange} /> + </label> + ); +} +``` + +```css +input { + margin: 5px; +} +label { + display: block; +} +``` + +</Solution> + +#### Filtering a list + +In this example, the `SearchBar` has its own `query` state that controls the text input. Its parent `FilterableList` component displays a `List` of items, but it doesn't take the search query into account. + +Use the `filterItems(foods, query)` function to filter the list according to the search query. To test your changes, verify that typing "s" into the input filters down the list to "Sushi", "Shish kebab", and "Dim sum". + +Note that `filterItems` is already implemented and imported so you don't need to write it yourself! + +<Hint> + +You will want to remove the `query` state and the `handleChange` handler from the `SearchBar`, and move them to the `FilterableList`. Then pass them down to `SearchBar` as `query` and `onChange` props. + +</Hint> + +```js +import { useState } from "react"; +import { foods, filterItems } from "./data.js"; + +export default function FilterableList() { + return ( + <> + <SearchBar /> + <hr /> + <List items={foods} /> + </> + ); +} + +function SearchBar() { + const [query, setQuery] = useState(""); + + function handleChange(e) { + setQuery(e.target.value); + } + + return ( + <label> + Search: <input value={query} onChange={handleChange} /> + </label> + ); +} + +function List({ items }) { + return ( + <table> + <tbody> + {items.map((food) => ( + <tr key={food.id}> + <td>{food.name}</td> + <td>{food.description}</td> + </tr> + ))} + </tbody> + </table> + ); +} +``` + +```js +export function filterItems(items, query) { + query = query.toLowerCase(); + return items.filter((item) => + item.name + .split(" ") + .some((word) => word.toLowerCase().startsWith(query)) + ); +} + +export const foods = [ + { + id: 0, + name: "Sushi", + description: + "Sushi is a traditional Japanese dish of prepared vinegared rice", + }, + { + id: 1, + name: "Dal", + description: + "The most common way of preparing dal is in the form of a soup to which onions, tomatoes and various spices may be added", + }, + { + id: 2, + name: "Pierogi", + description: + "Pierogi are filled dumplings made by wrapping unleavened dough around a savoury or sweet filling and cooking in boiling water", + }, + { + id: 3, + name: "Shish kebab", + description: + "Shish kebab is a popular meal of skewered and grilled cubes of meat.", + }, + { + id: 4, + name: "Dim sum", + description: + "Dim sum is a large range of small dishes that Cantonese people traditionally enjoy in restaurants for breakfast and lunch", + }, +]; +``` + +<Solution> + +Lift the `query` state up into the `FilterableList` component. Call `filterItems(foods, query)` to get the filtered list and pass it down to the `List`. Now changing the query input is reflected in the list: + +```js +import { useState } from "react"; +import { foods, filterItems } from "./data.js"; + +export default function FilterableList() { + const [query, setQuery] = useState(""); + const results = filterItems(foods, query); + + function handleChange(e) { + setQuery(e.target.value); + } + + return ( + <> + <SearchBar query={query} onChange={handleChange} /> + <hr /> + <List items={results} /> + </> + ); +} + +function SearchBar({ query, onChange }) { + return ( + <label> + Search: <input value={query} onChange={onChange} /> + </label> + ); +} + +function List({ items }) { + return ( + <table> + <tbody> + {items.map((food) => ( + <tr key={food.id}> + <td>{food.name}</td> + <td>{food.description}</td> + </tr> + ))} + </tbody> + </table> + ); +} +``` + +```js +export function filterItems(items, query) { + query = query.toLowerCase(); + return items.filter((item) => + item.name + .split(" ") + .some((word) => word.toLowerCase().startsWith(query)) + ); +} + +export const foods = [ + { + id: 0, + name: "Sushi", + description: + "Sushi is a traditional Japanese dish of prepared vinegared rice", + }, + { + id: 1, + name: "Dal", + description: + "The most common way of preparing dal is in the form of a soup to which onions, tomatoes and various spices may be added", + }, + { + id: 2, + name: "Pierogi", + description: + "Pierogi are filled dumplings made by wrapping unleavened dough around a savoury or sweet filling and cooking in boiling water", + }, + { + id: 3, + name: "Shish kebab", + description: + "Shish kebab is a popular meal of skewered and grilled cubes of meat.", + }, + { + id: 4, + name: "Dim sum", + description: + "Dim sum is a large range of small dishes that Cantonese people traditionally enjoy in restaurants for breakfast and lunch", + }, +]; +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/state-a-components-memory.md b/docs/src/learn/state-a-components-memory.md new file mode 100644 index 000000000..beafe6673 --- /dev/null +++ b/docs/src/learn/state-a-components-memory.md @@ -0,0 +1,1648 @@ +## Overview + +<p class="intro" markdown> + +Components often need to change what's on the screen as a result of an interaction. Typing into the form should update the input field, clicking "next" on an image carousel should change which image is displayed, clicking "buy" should put a product in the shopping cart. Components need to "remember" things: the current input value, the current image, the shopping cart. In React, this kind of component-specific memory is called _state_. + +</p> + +!!! summary "You will learn" + + - How to add a state variable with the [`useState`](/reference/react/useState) Hook + - What pair of values the `useState` Hook returns + - How to add more than one state variable + - Why state is called local + +## When a regular variable isn’t enough + +Here's a component that renders a sculpture image. Clicking the "Next" button should show the next sculpture by changing the `index` to `1`, then `2`, and so on. However, this **won't work** (you can try it!): + +```js +import { sculptureList } from "./data.js"; + +export default function Gallery() { + let index = 0; + + function handleClick() { + index = index + 1; + } + + let sculpture = sculptureList[index]; + return ( + <> + <button on_click={handleClick}>Next</button> + <h2> + <i>{sculpture.name} </i> + by {sculpture.artist} + </h2> + <h3> + ({index + 1} of {sculptureList.length}) + </h3> + <img src={sculpture.url} alt={sculpture.alt} /> + <p>{sculpture.description}</p> + </> + ); +} +``` + +```js +export const sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; +``` + +```css +h2 { + margin-top: 10px; + margin-bottom: 0; +} +h3 { + margin-top: 5px; + font-weight: normal; + font-size: 100%; +} +img { + width: 120px; + height: 120px; +} +button { + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +``` + +The `handleClick` event handler is updating a local variable, `index`. But two things prevent that change from being visible: + +1. **Local variables don't persist between renders.** When React renders this component a second time, it renders it from scratch—it doesn't consider any changes to the local variables. +2. **Changes to local variables won't trigger renders.** React doesn't realize it needs to render the component again with the new data. + +To update a component with new data, two things need to happen: + +1. **Retain** the data between renders. +2. **Trigger** React to render the component with new data (re-rendering). + +The [`useState`](/reference/react/useState) Hook provides those two things: + +1. A **state variable** to retain the data between renders. +2. A **state setter function** to update the variable and trigger React to render the component again. + +## Adding a state variable + +To add a state variable, import `useState` from React at the top of the file: + +```js +import { useState } from "react"; +``` + +Then, replace this line: + +```js +let index = 0; +``` + +with + +```js +const [index, setIndex] = useState(0); +``` + +`index` is a state variable and `setIndex` is the setter function. + +> The `[` and `]` syntax here is called [array destructuring](https://javascript.info/destructuring-assignment) and it lets you read values from an array. The array returned by `useState` always has exactly two items. + +This is how they work together in `handleClick`: + +```js +function handleClick() { + setIndex(index + 1); +} +``` + +Now clicking the "Next" button switches the current sculpture: + +```js +import { useState } from "react"; +import { sculptureList } from "./data.js"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + + function handleClick() { + setIndex(index + 1); + } + + let sculpture = sculptureList[index]; + return ( + <> + <button on_click={handleClick}>Next</button> + <h2> + <i>{sculpture.name} </i> + by {sculpture.artist} + </h2> + <h3> + ({index + 1} of {sculptureList.length}) + </h3> + <img src={sculpture.url} alt={sculpture.alt} /> + <p>{sculpture.description}</p> + </> + ); +} +``` + +```js +export const sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; +``` + +```css +h2 { + margin-top: 10px; + margin-bottom: 0; +} +h3 { + margin-top: 5px; + font-weight: normal; + font-size: 100%; +} +img { + width: 120px; + height: 120px; +} +button { + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +``` + +### Meet your first Hook + +In React, `useState`, as well as any other function starting with "`use`", is called a Hook. + +_Hooks_ are special functions that are only available while React is [rendering](/learn/render-and-commit#step-1-trigger-a-render) (which we'll get into in more detail on the next page). They let you "hook into" different React features. + +State is just one of those features, but you will meet the other Hooks later. + +<Pitfall> + +**Hooks—functions starting with `use`—can only be called at the top level of your components or [your own Hooks.](/learn/reusing-logic-with-custom-hooks)** You can't call Hooks inside conditions, loops, or other nested functions. Hooks are functions, but it's helpful to think of them as unconditional declarations about your component's needs. You "use" React features at the top of your component similar to how you "import" modules at the top of your file. + +</Pitfall> + +### Anatomy of `useState` + +When you call [`useState`](/reference/react/useState), you are telling React that you want this component to remember something: + +```js +const [index, setIndex] = useState(0); +``` + +In this case, you want React to remember `index`. + +<Note> + +The convention is to name this pair like `const [something, setSomething]`. You could name it anything you like, but conventions make things easier to understand across projects. + +</Note> + +The only argument to `useState` is the **initial value** of your state variable. In this example, the `index`'s initial value is set to `0` with `useState(0)`. + +Every time your component renders, `useState` gives you an array containing two values: + +1. The **state variable** (`index`) with the value you stored. +2. The **state setter function** (`setIndex`) which can update the state variable and trigger React to render the component again. + +Here's how that happens in action: + +```js +const [index, setIndex] = useState(0); +``` + +1. **Your component renders the first time.** Because you passed `0` to `useState` as the initial value for `index`, it will return `[0, setIndex]`. React remembers `0` is the latest state value. +2. **You update the state.** When a user clicks the button, it calls `setIndex(index + 1)`. `index` is `0`, so it's `setIndex(1)`. This tells React to remember `index` is `1` now and triggers another render. +3. **Your component's second render.** React still sees `useState(0)`, but because React _remembers_ that you set `index` to `1`, it returns `[1, setIndex]` instead. +4. And so on! + +## Giving a component multiple state variables + +You can have as many state variables of as many types as you like in one component. This component has two state variables, a number `index` and a boolean `showMore` that's toggled when you click "Show details": + +```js +import { useState } from "react"; +import { sculptureList } from "./data.js"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + const [showMore, setShowMore] = useState(false); + + function handleNextClick() { + setIndex(index + 1); + } + + function handleMoreClick() { + setShowMore(!showMore); + } + + let sculpture = sculptureList[index]; + return ( + <> + <button on_click={handleNextClick}>Next</button> + <h2> + <i>{sculpture.name} </i> + by {sculpture.artist} + </h2> + <h3> + ({index + 1} of {sculptureList.length}) + </h3> + <button on_click={handleMoreClick}> + {showMore ? "Hide" : "Show"} details + </button> + {showMore && <p>{sculpture.description}</p>} + <img src={sculpture.url} alt={sculpture.alt} /> + </> + ); +} +``` + +```js +export const sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; +``` + +```css +h2 { + margin-top: 10px; + margin-bottom: 0; +} +h3 { + margin-top: 5px; + font-weight: normal; + font-size: 100%; +} +img { + width: 120px; + height: 120px; +} +button { + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +``` + +It is a good idea to have multiple state variables if their state is unrelated, like `index` and `showMore` in this example. But if you find that you often change two state variables together, it might be easier to combine them into one. For example, if you have a form with many fields, it's more convenient to have a single state variable that holds an object than state variable per field. Read [Choosing the State Structure](/learn/choosing-the-state-structure) for more tips. + +<DeepDive> + +#### How does React know which state to return? + +You might have noticed that the `useState` call does not receive any information about _which_ state variable it refers to. There is no "identifier" that is passed to `useState`, so how does it know which of the state variables to return? Does it rely on some magic like parsing your functions? The answer is no. + +Instead, to enable their concise syntax, Hooks **rely on a stable call order on every render of the same component.** This works well in practice because if you follow the rule above ("only call Hooks at the top level"), Hooks will always be called in the same order. Additionally, a [linter plugin](https://www.npmjs.com/package/eslint-plugin-react-hooks) catches most mistakes. + +Internally, React holds an array of state pairs for every component. It also maintains the current pair index, which is set to `0` before rendering. Each time you call `useState`, React gives you the next state pair and increments the index. You can read more about this mechanism in [React Hooks: Not Magic, Just Arrays.](https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e) + +This example **doesn't use React** but it gives you an idea of how `useState` works internally: + +```js +let componentHooks = []; +let currentHookIndex = 0; + +// How useState works inside React (simplified). +function useState(initialState) { + let pair = componentHooks[currentHookIndex]; + if (pair) { + // This is not the first render, + // so the state pair already exists. + // Return it and prepare for next Hook call. + currentHookIndex++; + return pair; + } + + // This is the first time we're rendering, + // so create a state pair and store it. + pair = [initialState, setState]; + + function setState(nextState) { + // When the user requests a state change, + // put the new value into the pair. + pair[0] = nextState; + updateDOM(); + } + + // Store the pair for future renders + // and prepare for the next Hook call. + componentHooks[currentHookIndex] = pair; + currentHookIndex++; + return pair; +} + +function Gallery() { + // Each useState() call will get the next pair. + const [index, setIndex] = useState(0); + const [showMore, setShowMore] = useState(false); + + function handleNextClick() { + setIndex(index + 1); + } + + function handleMoreClick() { + setShowMore(!showMore); + } + + let sculpture = sculptureList[index]; + // This example doesn't use React, so + // return an output object instead of JSX. + return { + onNextClick: handleNextClick, + onMoreClick: handleMoreClick, + header: `${sculpture.name} by ${sculpture.artist}`, + counter: `${index + 1} of ${sculptureList.length}`, + more: `${showMore ? "Hide" : "Show"} details`, + description: showMore ? sculpture.description : null, + imageSrc: sculpture.url, + imageAlt: sculpture.alt, + }; +} + +function updateDOM() { + // Reset the current Hook index + // before rendering the component. + currentHookIndex = 0; + let output = Gallery(); + + // Update the DOM to match the output. + // This is the part React does for you. + nextButton.onclick = output.onNextClick; + header.textContent = output.header; + moreButton.onclick = output.onMoreClick; + moreButton.textContent = output.more; + image.src = output.imageSrc; + image.alt = output.imageAlt; + if (output.description !== null) { + description.textContent = output.description; + description.style.display = ""; + } else { + description.style.display = "none"; + } +} + +let nextButton = document.getElementById("nextButton"); +let header = document.getElementById("header"); +let moreButton = document.getElementById("moreButton"); +let description = document.getElementById("description"); +let image = document.getElementById("image"); +let sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; + +// Make UI match the initial state. +updateDOM(); +``` + +```html +<button id="nextButton">Next</button> +<h3 id="header"></h3> +<button id="moreButton"></button> +<p id="description"></p> +<img id="image" /> + +<style> + * { + box-sizing: border-box; + } + body { + font-family: sans-serif; + margin: 20px; + padding: 0; + } + button { + display: block; + margin-bottom: 10px; + } +</style> +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +``` + +You don't have to understand it to use React, but you might find this a helpful mental model. + +</DeepDive> + +## State is isolated and private + +State is local to a component instance on the screen. In other words, **if you render the same component twice, each copy will have completely isolated state!** Changing one of them will not affect the other. + +In this example, the `Gallery` component from earlier is rendered twice with no changes to its logic. Try clicking the buttons inside each of the galleries. Notice that their state is independent: + +```js +import Gallery from "./Gallery.js"; + +export default function Page() { + return ( + <div className="Page"> + <Gallery /> + <Gallery /> + </div> + ); +} +``` + +```js +import { useState } from "react"; +import { sculptureList } from "./data.js"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + const [showMore, setShowMore] = useState(false); + + function handleNextClick() { + setIndex(index + 1); + } + + function handleMoreClick() { + setShowMore(!showMore); + } + + let sculpture = sculptureList[index]; + return ( + <section> + <button on_click={handleNextClick}>Next</button> + <h2> + <i>{sculpture.name} </i> + by {sculpture.artist} + </h2> + <h3> + ({index + 1} of {sculptureList.length}) + </h3> + <button on_click={handleMoreClick}> + {showMore ? "Hide" : "Show"} details + </button> + {showMore && <p>{sculpture.description}</p>} + <img src={sculpture.url} alt={sculpture.alt} /> + </section> + ); +} +``` + +```js +export const sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +.Page > * { + float: left; + width: 50%; + padding: 10px; +} +h2 { + margin-top: 10px; + margin-bottom: 0; +} +h3 { + margin-top: 5px; + font-weight: normal; + font-size: 100%; +} +img { + width: 120px; + height: 120px; +} +button { + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +``` + +This is what makes state different from regular variables that you might declare at the top of your module. State is not tied to a particular function call or a place in the code, but it's "local" to the specific place on the screen. You rendered two `<Gallery />` components, so their state is stored separately. + +Also notice how the `Page` component doesn't "know" anything about the `Gallery` state or even whether it has any. Unlike props, **state is fully private to the component declaring it.** The parent component can't change it. This lets you add state to any component or remove it without impacting the rest of the components. + +What if you wanted both galleries to keep their states in sync? The right way to do it in React is to _remove_ state from child components and add it to their closest shared parent. The next few pages will focus on organizing state of a single component, but we will return to this topic in [Sharing State Between Components.](/learn/sharing-state-between-components) + +<Recap> + +- Use a state variable when a component needs to "remember" some information between renders. +- State variables are declared by calling the `useState` Hook. +- Hooks are special functions that start with `use`. They let you "hook into" React features like state. +- Hooks might remind you of imports: they need to be called unconditionally. Calling Hooks, including `useState`, is only valid at the top level of a component or another Hook. +- The `useState` Hook returns a pair of values: the current state and the function to update it. +- You can have more than one state variable. Internally, React matches them up by their order. +- State is private to the component. If you render it in two places, each copy gets its own state. + +</Recap> + +<Challenges> + +#### Complete the gallery + +When you press "Next" on the last sculpture, the code crashes. Fix the logic to prevent the crash. You may do this by adding extra logic to event handler or by disabling the button when the action is not possible. + +After fixing the crash, add a "Previous" button that shows the previous sculpture. It shouldn't crash on the first sculpture. + +```js +import { useState } from "react"; +import { sculptureList } from "./data.js"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + const [showMore, setShowMore] = useState(false); + + function handleNextClick() { + setIndex(index + 1); + } + + function handleMoreClick() { + setShowMore(!showMore); + } + + let sculpture = sculptureList[index]; + return ( + <> + <button on_click={handleNextClick}>Next</button> + <h2> + <i>{sculpture.name} </i> + by {sculpture.artist} + </h2> + <h3> + ({index + 1} of {sculptureList.length}) + </h3> + <button on_click={handleMoreClick}> + {showMore ? "Hide" : "Show"} details + </button> + {showMore && <p>{sculpture.description}</p>} + <img src={sculpture.url} alt={sculpture.alt} /> + </> + ); +} +``` + +```js +export const sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +.Page > * { + float: left; + width: 50%; + padding: 10px; +} +h2 { + margin-top: 10px; + margin-bottom: 0; +} +h3 { + margin-top: 5px; + font-weight: normal; + font-size: 100%; +} +img { + width: 120px; + height: 120px; +} +``` + +<Solution> + +This adds a guarding condition inside both event handlers and disables the buttons when needed: + +```js +import { useState } from "react"; +import { sculptureList } from "./data.js"; + +export default function Gallery() { + const [index, setIndex] = useState(0); + const [showMore, setShowMore] = useState(false); + + let hasPrev = index > 0; + let hasNext = index < sculptureList.length - 1; + + function handlePrevClick() { + if (hasPrev) { + setIndex(index - 1); + } + } + + function handleNextClick() { + if (hasNext) { + setIndex(index + 1); + } + } + + function handleMoreClick() { + setShowMore(!showMore); + } + + let sculpture = sculptureList[index]; + return ( + <> + <button on_click={handlePrevClick} disabled={!hasPrev}> + Previous + </button> + <button on_click={handleNextClick} disabled={!hasNext}> + Next + </button> + <h2> + <i>{sculpture.name} </i> + by {sculpture.artist} + </h2> + <h3> + ({index + 1} of {sculptureList.length}) + </h3> + <button on_click={handleMoreClick}> + {showMore ? "Hide" : "Show"} details + </button> + {showMore && <p>{sculpture.description}</p>} + <img src={sculpture.url} alt={sculpture.alt} /> + </> + ); +} +``` + +```js +export const sculptureList = [ + { + name: "Homenaje a la Neurocirugía", + artist: "Marta Colvin Andrade", + description: + "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.", + url: "https://i.imgur.com/Mx7dA2Y.jpg", + alt: "A bronze statue of two crossed hands delicately holding a human brain in their fingertips.", + }, + { + name: "Floralis Genérica", + artist: "Eduardo Catalano", + description: + "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.", + url: "https://i.imgur.com/ZF6s192m.jpg", + alt: "A gigantic metallic flower sculpture with reflective mirror-like petals and strong stamens.", + }, + { + name: "Eternal Presence", + artist: "John Woodrow Wilson", + description: + 'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."', + url: "https://i.imgur.com/aTtVpES.jpg", + alt: "The sculpture depicting a human head seems ever-present and solemn. It radiates calm and serenity.", + }, + { + name: "Moai", + artist: "Unknown Artist", + description: + "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.", + url: "https://i.imgur.com/RCwLEoQm.jpg", + alt: "Three monumental stone busts with the heads that are disproportionately large with somber faces.", + }, + { + name: "Blue Nana", + artist: "Niki de Saint Phalle", + description: + "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.", + url: "https://i.imgur.com/Sd1AgUOm.jpg", + alt: "A large mosaic sculpture of a whimsical dancing female figure in a colorful costume emanating joy.", + }, + { + name: "Ultimate Form", + artist: "Barbara Hepworth", + description: + "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.", + url: "https://i.imgur.com/2heNQDcm.jpg", + alt: "A tall sculpture made of three elements stacked on each other reminding of a human figure.", + }, + { + name: "Cavaliere", + artist: "Lamidi Olonade Fakeye", + description: + "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.", + url: "https://i.imgur.com/wIdGuZwm.png", + alt: "An intricate wood sculpture of a warrior with a focused face on a horse adorned with patterns.", + }, + { + name: "Big Bellies", + artist: "Alina Szapocznikow", + description: + "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.", + url: "https://i.imgur.com/AlHTAdDm.jpg", + alt: "The sculpture reminds a cascade of folds, quite different from bellies in classical sculptures.", + }, + { + name: "Terracotta Army", + artist: "Unknown Artist", + description: + "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.", + url: "https://i.imgur.com/HMFmH6m.jpg", + alt: "12 terracotta sculptures of solemn warriors, each with a unique facial expression and armor.", + }, + { + name: "Lunar Landscape", + artist: "Louise Nevelson", + description: + "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.", + url: "https://i.imgur.com/rN7hY6om.jpg", + alt: "A black matte sculpture where the individual elements are initially indistinguishable.", + }, + { + name: "Aureole", + artist: "Ranjani Shettar", + description: + 'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."', + url: "https://i.imgur.com/okTpbHhm.jpg", + alt: "A pale wire-like sculpture mounted on concrete wall and descending on the floor. It appears light.", + }, + { + name: "Hippos", + artist: "Taipei Zoo", + description: + "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.", + url: "https://i.imgur.com/6o5Vuyu.jpg", + alt: "A group of bronze hippo sculptures emerging from the sett sidewalk as if they were swimming.", + }, +]; +``` + +```css +button { + display: block; + margin-bottom: 10px; +} +.Page > * { + float: left; + width: 50%; + padding: 10px; +} +h2 { + margin-top: 10px; + margin-bottom: 0; +} +h3 { + margin-top: 5px; + font-weight: normal; + font-size: 100%; +} +img { + width: 120px; + height: 120px; +} +``` + +Notice how `hasPrev` and `hasNext` are used _both_ for the returned JSX and inside the event handlers! This handy pattern works because event handler functions ["close over"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures) any variables declared while rendering. + +</Solution> + +#### Fix stuck form inputs + +When you type into the input fields, nothing appears. It's like the input values are "stuck" with empty strings. The `value` of the first `<input>` is set to always match the `firstName` variable, and the `value` for the second `<input>` is set to always match the `lastName` variable. This is correct. Both inputs have `onChange` event handlers, which try to update the variables based on the latest user input (`e.target.value`). However, the variables don't seem to "remember" their values between re-renders. Fix this by using state variables instead. + +```js +export default function Form() { + let firstName = ""; + let lastName = ""; + + function handleFirstNameChange(e) { + firstName = e.target.value; + } + + function handleLastNameChange(e) { + lastName = e.target.value; + } + + function handleReset() { + firstName = ""; + lastName = ""; + } + + return ( + <form onSubmit={(e) => e.preventDefault()}> + <input + placeholder="First name" + value={firstName} + onChange={handleFirstNameChange} + /> + <input + placeholder="Last name" + value={lastName} + onChange={handleLastNameChange} + /> + <h1> + Hi, {firstName} {lastName} + </h1> + <button on_click={handleReset}>Reset</button> + </form> + ); +} +``` + +```css +h1 { + margin-top: 10px; +} +``` + +<Solution> + +First, import `useState` from React. Then replace `firstName` and `lastName` with state variables declared by calling `useState`. Finally, replace every `firstName = ...` assignment with `setFirstName(...)`, and do the same for `lastName`. Don't forget to update `handleReset` too so that the reset button works. + +```js +import { useState } from "react"; + +export default function Form() { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + + function handleFirstNameChange(e) { + setFirstName(e.target.value); + } + + function handleLastNameChange(e) { + setLastName(e.target.value); + } + + function handleReset() { + setFirstName(""); + setLastName(""); + } + + return ( + <form onSubmit={(e) => e.preventDefault()}> + <input + placeholder="First name" + value={firstName} + onChange={handleFirstNameChange} + /> + <input + placeholder="Last name" + value={lastName} + onChange={handleLastNameChange} + /> + <h1> + Hi, {firstName} {lastName} + </h1> + <button on_click={handleReset}>Reset</button> + </form> + ); +} +``` + +```css +h1 { + margin-top: 10px; +} +``` + +</Solution> + +#### Fix a crash + +Here is a small form that is supposed to let the user leave some feedback. When the feedback is submitted, it's supposed to display a thank-you message. However, it crashes with an error message saying "Rendered fewer hooks than expected". Can you spot the mistake and fix it? + +<Hint> + +Are there any limitations on _where_ Hooks may be called? Does this component break any rules? Check if there are any comments disabling the linter checks--this is where the bugs often hide! + +</Hint> + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + const [isSent, setIsSent] = useState(false); + if (isSent) { + return <h1>Thank you!</h1>; + } else { + // eslint-disable-next-line + const [message, setMessage] = useState(""); + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + alert(`Sending: "${message}"`); + setIsSent(true); + }} + > + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <br /> + <button type="submit">Send</button> + </form> + ); + } +} +``` + +<Solution> + +Hooks can only be called at the top level of the component function. Here, the first `isSent` definition follows this rule, but the `message` definition is nested in a condition. + +Move it out of the condition to fix the issue: + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + const [isSent, setIsSent] = useState(false); + const [message, setMessage] = useState(""); + + if (isSent) { + return <h1>Thank you!</h1>; + } else { + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + alert(`Sending: "${message}"`); + setIsSent(true); + }} + > + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <br /> + <button type="submit">Send</button> + </form> + ); + } +} +``` + +Remember, Hooks must be called unconditionally and always in the same order! + +You could also remove the unnecessary `else` branch to reduce the nesting. However, it's still important that all calls to Hooks happen _before_ the first `return`. + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + const [isSent, setIsSent] = useState(false); + const [message, setMessage] = useState(""); + + if (isSent) { + return <h1>Thank you!</h1>; + } + + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + alert(`Sending: "${message}"`); + setIsSent(true); + }} + > + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <br /> + <button type="submit">Send</button> + </form> + ); +} +``` + +Try moving the second `useState` call after the `if` condition and notice how this breaks it again. + +If your linter is [configured for React](/learn/editor-setup#linting), you should see a lint error when you make a mistake like this. If you don't see an error when you try the faulty code locally, you need to set up linting for your project. + +</Solution> + +#### Remove unnecessary state + +When the button is clicked, this example should ask for the user's name and then display an alert greeting them. You tried to use state to keep the name, but for some reason it always shows "Hello, !". + +To fix this code, remove the unnecessary state variable. (We will discuss about [why this didn't work](/learn/state-as-a-snapshot) later.) + +Can you explain why this state variable was unnecessary? + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + const [name, setName] = useState(""); + + function handleClick() { + setName(prompt("What is your name?")); + alert(`Hello, ${name}!`); + } + + return <button on_click={handleClick}>Greet</button>; +} +``` + +<Solution> + +Here is a fixed version that uses a regular `name` variable declared in the function that needs it: + +```js +import { useState } from "react"; + +export default function FeedbackForm() { + function handleClick() { + const name = prompt("What is your name?"); + alert(`Hello, ${name}!`); + } + + return <button on_click={handleClick}>Greet</button>; +} +``` + +A state variable is only necessary to keep information between re-renders of a component. Within a single event handler, a regular variable will do fine. Don't introduce state variables when a regular variable works well. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/state-as-a-snapshot.md b/docs/src/learn/state-as-a-snapshot.md new file mode 100644 index 000000000..9cc8f7e63 --- /dev/null +++ b/docs/src/learn/state-as-a-snapshot.md @@ -0,0 +1,468 @@ +## Overview + +<p class="intro" markdown> + +State variables might look like regular JavaScript variables that you can read and write to. However, state behaves more like a snapshot. Setting it does not change the state variable you already have, but instead triggers a re-render. + +</p> + +!!! summary "You will learn" + + - How setting state triggers re-renders + - When and how state updates + - Why state does not update immediately after you set it + - How event handlers access a "snapshot" of the state + +## Setting state triggers renders + +You might think of your user interface as changing directly in response to the user event like a click. In React, it works a little differently from this mental model. On the previous page, you saw that [setting state requests a re-render](/learn/render-and-commit#step-1-trigger-a-render) from React. This means that for an interface to react to the event, you need to _update the state_. + +In this example, when you press "send", `setIsSent(true)` tells React to re-render the UI: + +```js +import { useState } from "react"; + +export default function Form() { + const [isSent, setIsSent] = useState(false); + const [message, setMessage] = useState("Hi!"); + if (isSent) { + return <h1>Your message is on its way!</h1>; + } + return ( + <form + onSubmit={(e) => { + e.preventDefault(); + setIsSent(true); + sendMessage(message); + }} + > + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button type="submit">Send</button> + </form> + ); +} + +function sendMessage(message) { + // ... +} +``` + +```css +label, +textarea { + margin-bottom: 10px; + display: block; +} +``` + +Here's what happens when you click the button: + +1. The `onSubmit` event handler executes. +2. `setIsSent(true)` sets `isSent` to `true` and queues a new render. +3. React re-renders the component according to the new `isSent` value. + +Let's take a closer look at the relationship between state and rendering. + +## Rendering takes a snapshot in time + +["Rendering"](/learn/render-and-commit#step-2-react-renders-your-components) means that React is calling your component, which is a function. The JSX you return from that function is like a snapshot of the UI in time. Its props, event handlers, and local variables were all calculated **using its state at the time of the render.** + +Unlike a photograph or a movie frame, the UI "snapshot" you return is interactive. It includes logic like event handlers that specify what happens in response to inputs. React updates the screen to match this snapshot and connects the event handlers. As a result, pressing a button will trigger the click handler from your JSX. + +When React re-renders a component: + +1. React calls your function again. +2. Your function returns a new JSX snapshot. +3. React then updates the screen to match the snapshot you've returned. + +<IllustrationBlock sequential> + <Illustration caption="React executing the function" src="/images/docs/illustrations/i_render1.png" /> + <Illustration caption="Calculating the snapshot" src="/images/docs/illustrations/i_render2.png" /> + <Illustration caption="Updating the DOM tree" src="/images/docs/illustrations/i_render3.png" /> +</IllustrationBlock> + +As a component's memory, state is not like a regular variable that disappears after your function returns. State actually "lives" in React itself--as if on a shelf!--outside of your function. When React calls your component, it gives you a snapshot of the state for that particular render. Your component returns a snapshot of the UI with a fresh set of props and event handlers in its JSX, all calculated **using the state values from that render!** + +<IllustrationBlock sequential> + <Illustration caption="You tell React to update the state" src="/images/docs/illustrations/i_state-snapshot1.png" /> + <Illustration caption="React updates the state value" src="/images/docs/illustrations/i_state-snapshot2.png" /> + <Illustration caption="React passes a snapshot of the state value into the component" src="/images/docs/illustrations/i_state-snapshot3.png" /> +</IllustrationBlock> + +Here's a little experiment to show you how this works. In this example, you might expect that clicking the "+3" button would increment the counter three times because it calls `setNumber(number + 1)` three times. + +See what happens when you click the "+3" button: + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber(number + 1); + setNumber(number + 1); + setNumber(number + 1); + }} + > + +3 + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +Notice that `number` only increments once per click! + +**Setting state only changes it for the _next_ render.** During the first render, `number` was `0`. This is why, in _that render's_ `on_click` handler, the value of `number` is still `0` even after `setNumber(number + 1)` was called: + +```js +<button + on_click={() => { + setNumber(number + 1); + setNumber(number + 1); + setNumber(number + 1); + }} +> + +3 +</button> +``` + +Here is what this button's click handler tells React to do: + +1. `setNumber(number + 1)`: `number` is `0` so `setNumber(0 + 1)`. + - React prepares to change `number` to `1` on the next render. +2. `setNumber(number + 1)`: `number` is `0` so `setNumber(0 + 1)`. + - React prepares to change `number` to `1` on the next render. +3. `setNumber(number + 1)`: `number` is `0` so `setNumber(0 + 1)`. + - React prepares to change `number` to `1` on the next render. + +Even though you called `setNumber(number + 1)` three times, in _this render's_ event handler `number` is always `0`, so you set the state to `1` three times. This is why, after your event handler finishes, React re-renders the component with `number` equal to `1` rather than `3`. + +You can also visualize this by mentally substituting state variables with their values in your code. Since the `number` state variable is `0` for _this render_, its event handler looks like this: + +```js +<button + on_click={() => { + setNumber(0 + 1); + setNumber(0 + 1); + setNumber(0 + 1); + }} +> + +3 +</button> +``` + +For the next render, `number` is `1`, so _that render's_ click handler looks like this: + +```js +<button + on_click={() => { + setNumber(1 + 1); + setNumber(1 + 1); + setNumber(1 + 1); + }} +> + +3 +</button> +``` + +This is why clicking the button again will set the counter to `2`, then to `3` on the next click, and so on. + +## State over time + +Well, that was fun. Try to guess what clicking this button will alert: + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber(number + 5); + alert(number); + }} + > + +5 + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +If you use the substitution method from before, you can guess that the alert shows "0": + +```js +setNumber(0 + 5); +alert(0); +``` + +But what if you put a timer on the alert, so it only fires _after_ the component re-rendered? Would it say "0" or "5"? Have a guess! + +```js +import { useState } from "react"; + +export default function Counter() { + const [number, setNumber] = useState(0); + + return ( + <> + <h1>{number}</h1> + <button + on_click={() => { + setNumber(number + 5); + setTimeout(() => { + alert(number); + }, 3000); + }} + > + +5 + </button> + </> + ); +} +``` + +```css +button { + display: inline-block; + margin: 10px; + font-size: 20px; +} +h1 { + display: inline-block; + margin: 10px; + width: 30px; + text-align: center; +} +``` + +Surprised? If you use the substitution method, you can see the "snapshot" of the state passed to the alert. + +```js +setNumber(0 + 5); +setTimeout(() => { + alert(0); +}, 3000); +``` + +The state stored in React may have changed by the time the alert runs, but it was scheduled using a snapshot of the state at the time the user interacted with it! + +**A state variable's value never changes within a render,** even if its event handler's code is asynchronous. Inside _that render's_ `on_click`, the value of `number` continues to be `0` even after `setNumber(number + 5)` was called. Its value was "fixed" when React "took the snapshot" of the UI by calling your component. + +Here is an example of how that makes your event handlers less prone to timing mistakes. Below is a form that sends a message with a five-second delay. Imagine this scenario: + +1. You press the "Send" button, sending "Hello" to Alice. +2. Before the five-second delay ends, you change the value of the "To" field to "Bob". + +What do you expect the `alert` to display? Would it display, "You said Hello to Alice"? Or would it display, "You said Hello to Bob"? Make a guess based on what you know, and then try it: + +```js +import { useState } from "react"; + +export default function Form() { + const [to, setTo] = useState("Alice"); + const [message, setMessage] = useState("Hello"); + + function handleSubmit(e) { + e.preventDefault(); + setTimeout(() => { + alert(`You said ${message} to ${to}`); + }, 5000); + } + + return ( + <form onSubmit={handleSubmit}> + <label> + To:{" "} + <select value={to} onChange={(e) => setTo(e.target.value)}> + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + </select> + </label> + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button type="submit">Send</button> + </form> + ); +} +``` + +```css +label, +textarea { + margin-bottom: 10px; + display: block; +} +``` + +**React keeps the state values "fixed" within one render's event handlers.** You don't need to worry whether the state has changed while the code is running. + +But what if you wanted to read the latest state before a re-render? You'll want to use a [state updater function](/learn/queueing-a-series-of-state-updates), covered on the next page! + +<Recap> + +- Setting state requests a new render. +- React stores state outside of your component, as if on a shelf. +- When you call `useState`, React gives you a snapshot of the state _for that render_. +- Variables and event handlers don't "survive" re-renders. Every render has its own event handlers. +- Every render (and functions inside it) will always "see" the snapshot of the state that React gave to _that_ render. +- You can mentally substitute state in event handlers, similarly to how you think about the rendered JSX. +- Event handlers created in the past have the state values from the render in which they were created. + +</Recap> + +<Challenges> + +#### Implement a traffic light + +Here is a crosswalk light component that toggles when the button is pressed: + +```js +import { useState } from "react"; + +export default function TrafficLight() { + const [walk, setWalk] = useState(true); + + function handleClick() { + setWalk(!walk); + } + + return ( + <> + <button on_click={handleClick}> + Change to {walk ? "Stop" : "Walk"} + </button> + <h1 + style={{ + color: walk ? "darkgreen" : "darkred", + }} + > + {walk ? "Walk" : "Stop"} + </h1> + </> + ); +} +``` + +```css +h1 { + margin-top: 20px; +} +``` + +Add an `alert` to the click handler. When the light is green and says "Walk", clicking the button should say "Stop is next". When the light is red and says "Stop", clicking the button should say "Walk is next". + +Does it make a difference whether you put the `alert` before or after the `setWalk` call? + +<Solution> + +Your `alert` should look like this: + +```js +import { useState } from "react"; + +export default function TrafficLight() { + const [walk, setWalk] = useState(true); + + function handleClick() { + setWalk(!walk); + alert(walk ? "Stop is next" : "Walk is next"); + } + + return ( + <> + <button on_click={handleClick}> + Change to {walk ? "Stop" : "Walk"} + </button> + <h1 + style={{ + color: walk ? "darkgreen" : "darkred", + }} + > + {walk ? "Walk" : "Stop"} + </h1> + </> + ); +} +``` + +```css +h1 { + margin-top: 20px; +} +``` + +Whether you put it before or after the `setWalk` call makes no difference. That render's value of `walk` is fixed. Calling `setWalk` will only change it for the _next_ render, but will not affect the event handler from the previous render. + +This line might seem counter-intuitive at first: + +```js +alert(walk ? "Stop is next" : "Walk is next"); +``` + +But it makes sense if you read it as: "If the traffic light shows 'Walk now', the message should say 'Stop is next.'" The `walk` variable inside your event handler matches that render's value of `walk` and does not change. + +You can verify that this is correct by applying the substitution method. When `walk` is `true`, you get: + +```js +<button on_click={() => { + setWalk(false); + alert('Stop is next'); +}}> + Change to Stop +</button> +<h1 style={{color: 'darkgreen'}}> + Walk +</h1> +``` + +So clicking "Change to Stop" queues a render with `walk` set to `false`, and alerts "Stop is next". + +</Solution> + +</Challenges> diff --git a/docs/src/learn/synchronizing-with-effects.md b/docs/src/learn/synchronizing-with-effects.md new file mode 100644 index 000000000..96f19f6c9 --- /dev/null +++ b/docs/src/learn/synchronizing-with-effects.md @@ -0,0 +1,1546 @@ +## Overview + +<p class="intro" markdown> + +Some components need to synchronize with external systems. For example, you might want to control a non-React component based on the React state, set up a server connection, or send an analytics log when a component appears on the screen. _Effects_ let you run some code after rendering so that you can synchronize your component with some system outside of React. + +</p> + +!!! summary "You will learn" + + - What Effects are + - How Effects are different from events + - How to declare an Effect in your component + - How to skip re-running an Effect unnecessarily + - Why Effects run twice in development and how to fix them + +## What are Effects and how are they different from events? + +Before getting to Effects, you need to be familiar with two types of logic inside React components: + +- **Rendering code** (introduced in [Describing the UI](/learn/describing-the-ui)) lives at the top level of your component. This is where you take the props and state, transform them, and return the JSX you want to see on the screen. [Rendering code must be pure.](/learn/keeping-components-pure) Like a math formula, it should only _calculate_ the result, but not do anything else. + +- **Event handlers** (introduced in [Adding Interactivity](/learn/adding-interactivity)) are nested functions inside your components that _do_ things rather than just calculate them. An event handler might update an input field, submit an HTTP POST request to buy a product, or navigate the user to another screen. Event handlers contain ["side effects"](<https://en.wikipedia.org/wiki/Side_effect_(computer_science)>) (they change the program's state) caused by a specific user action (for example, a button click or typing). + +Sometimes this isn't enough. Consider a `ChatRoom` component that must connect to the chat server whenever it's visible on the screen. Connecting to a server is not a pure calculation (it's a side effect) so it can't happen during rendering. However, there is no single particular event like a click that causes `ChatRoom` to be displayed. + +**_Effects_ let you specify side effects that are caused by rendering itself, rather than by a particular event.** Sending a message in the chat is an _event_ because it is directly caused by the user clicking a specific button. However, setting up a server connection is an _Effect_ because it should happen no matter which interaction caused the component to appear. Effects run at the end of a [commit](/learn/render-and-commit) after the screen updates. This is a good time to synchronize the React components with some external system (like network or a third-party library). + +<Note> + +Here and later in this text, capitalized "Effect" refers to the React-specific definition above, i.e. a side effect caused by rendering. To refer to the broader programming concept, we'll say "side effect". + +</Note> + +## You might not need an Effect + +**Don't rush to add Effects to your components.** Keep in mind that Effects are typically used to "step out" of your React code and synchronize with some _external_ system. This includes browser APIs, third-party widgets, network, and so on. If your Effect only adjusts some state based on other state, [you might not need an Effect.](/learn/you-might-not-need-an-effect) + +## How to write an Effect + +To write an Effect, follow these three steps: + +1. **Declare an Effect.** By default, your Effect will run after every render. +2. **Specify the Effect dependencies.** Most Effects should only re-run _when needed_ rather than after every render. For example, a fade-in animation should only trigger when a component appears. Connecting and disconnecting to a chat room should only happen when the component appears and disappears, or when the chat room changes. You will learn how to control this by specifying _dependencies._ +3. **Add cleanup if needed.** Some Effects need to specify how to stop, undo, or clean up whatever they were doing. For example, "connect" needs "disconnect", "subscribe" needs "unsubscribe", and "fetch" needs either "cancel" or "ignore". You will learn how to do this by returning a _cleanup function_. + +Let's look at each of these steps in detail. + +### Step 1: Declare an Effect + +To declare an Effect in your component, import the [`useEffect` Hook](/reference/react/useEffect) from React: + +```js +import { useEffect } from "react"; +``` + +Then, call it at the top level of your component and put some code inside your Effect: + +```js +function MyComponent() { + useEffect(() => { + // Code here will run after *every* render + }); + return <div />; +} +``` + +Every time your component renders, React will update the screen _and then_ run the code inside `useEffect`. In other words, **`useEffect` "delays" a piece of code from running until that render is reflected on the screen.** + +Let's see how you can use an Effect to synchronize with an external system. Consider a `<VideoPlayer>` React component. It would be nice to control whether it's playing or paused by passing an `isPlaying` prop to it: + +```js +<VideoPlayer isPlaying={isPlaying} /> +``` + +Your custom `VideoPlayer` component renders the built-in browser [`<video>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video) tag: + +```js +function VideoPlayer({ src, isPlaying }) { + // TODO: do something with isPlaying + return <video src={src} />; +} +``` + +However, the browser `<video>` tag does not have an `isPlaying` prop. The only way to control it is to manually call the [`play()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play) and [`pause()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause) methods on the DOM element. **You need to synchronize the value of `isPlaying` prop, which tells whether the video _should_ currently be playing, with calls like `play()` and `pause()`.** + +We'll need to first [get a ref](/learn/manipulating-the-dom-with-refs) to the `<video>` DOM node. + +You might be tempted to try to call `play()` or `pause()` during rendering, but that isn't correct: + +```js +import { useState, useRef, useEffect } from "react"; + +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + + if (isPlaying) { + ref.current.play(); // Calling these while rendering isn't allowed. + } else { + ref.current.pause(); // Also, this crashes. + } + + return <video ref={ref} src={src} loop playsInline />; +} + +export default function App() { + const [isPlaying, setIsPlaying] = useState(false); + return ( + <> + <button on_click={() => setIsPlaying(!isPlaying)}> + {isPlaying ? "Pause" : "Play"} + </button> + <VideoPlayer + isPlaying={isPlaying} + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + /> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 20px; +} +video { + width: 250px; +} +``` + +The reason this code isn't correct is that it tries to do something with the DOM node during rendering. In React, [rendering should be a pure calculation](/learn/keeping-components-pure) of JSX and should not contain side effects like modifying the DOM. + +Moreover, when `VideoPlayer` is called for the first time, its DOM does not exist yet! There isn't a DOM node yet to call `play()` or `pause()` on, because React doesn't know what DOM to create until you return the JSX. + +The solution here is to **wrap the side effect with `useEffect` to move it out of the rendering calculation:** + +```js +import { useEffect, useRef } from "react"; + +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + + useEffect(() => { + if (isPlaying) { + ref.current.play(); + } else { + ref.current.pause(); + } + }); + + return <video ref={ref} src={src} loop playsInline />; +} +``` + +By wrapping the DOM update in an Effect, you let React update the screen first. Then your Effect runs. + +When your `VideoPlayer` component renders (either the first time or if it re-renders), a few things will happen. First, React will update the screen, ensuring the `<video>` tag is in the DOM with the right props. Then React will run your Effect. Finally, your Effect will call `play()` or `pause()` depending on the value of `isPlaying`. + +Press Play/Pause multiple times and see how the video player stays synchronized to the `isPlaying` value: + +```js +import { useState, useRef, useEffect } from "react"; + +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + + useEffect(() => { + if (isPlaying) { + ref.current.play(); + } else { + ref.current.pause(); + } + }); + + return <video ref={ref} src={src} loop playsInline />; +} + +export default function App() { + const [isPlaying, setIsPlaying] = useState(false); + return ( + <> + <button on_click={() => setIsPlaying(!isPlaying)}> + {isPlaying ? "Pause" : "Play"} + </button> + <VideoPlayer + isPlaying={isPlaying} + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + /> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 20px; +} +video { + width: 250px; +} +``` + +In this example, the "external system" you synchronized to React state was the browser media API. You can use a similar approach to wrap legacy non-React code (like jQuery plugins) into declarative React components. + +Note that controlling a video player is much more complex in practice. Calling `play()` may fail, the user might play or pause using the built-in browser controls, and so on. This example is very simplified and incomplete. + +<Pitfall> + +By default, Effects run after _every_ render. This is why code like this will **produce an infinite loop:** + +```js +const [count, setCount] = useState(0); +useEffect(() => { + setCount(count + 1); +}); +``` + +Effects run as a _result_ of rendering. Setting state _triggers_ rendering. Setting state immediately in an Effect is like plugging a power outlet into itself. The Effect runs, it sets the state, which causes a re-render, which causes the Effect to run, it sets the state again, this causes another re-render, and so on. + +Effects should usually synchronize your components with an _external_ system. If there's no external system and you only want to adjust some state based on other state, [you might not need an Effect.](/learn/you-might-not-need-an-effect) + +</Pitfall> + +### Step 2: Specify the Effect dependencies + +By default, Effects run after _every_ render. Often, this is **not what you want:** + +- Sometimes, it's slow. Synchronizing with an external system is not always instant, so you might want to skip doing it unless it's necessary. For example, you don't want to reconnect to the chat server on every keystroke. +- Sometimes, it's wrong. For example, you don't want to trigger a component fade-in animation on every keystroke. The animation should only play once when the component appears for the first time. + +To demonstrate the issue, here is the previous example with a few `console.log` calls and a text input that updates the parent component's state. Notice how typing causes the Effect to re-run: + +```js +import { useState, useRef, useEffect } from "react"; + +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + + useEffect(() => { + if (isPlaying) { + console.log("Calling video.play()"); + ref.current.play(); + } else { + console.log("Calling video.pause()"); + ref.current.pause(); + } + }); + + return <video ref={ref} src={src} loop playsInline />; +} + +export default function App() { + const [isPlaying, setIsPlaying] = useState(false); + const [text, setText] = useState(""); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={() => setIsPlaying(!isPlaying)}> + {isPlaying ? "Pause" : "Play"} + </button> + <VideoPlayer + isPlaying={isPlaying} + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + /> + </> + ); +} +``` + +```css +input, +button { + display: block; + margin-bottom: 20px; +} +video { + width: 250px; +} +``` + +You can tell React to **skip unnecessarily re-running the Effect** by specifying an array of _dependencies_ as the second argument to the `useEffect` call. Start by adding an empty `[]` array to the above example on line 14: + +```js +useEffect(() => { + // ... +}, []); +``` + +You should see an error saying `React Hook useEffect has a missing dependency: 'isPlaying'`: + +```js +import { useState, useRef, useEffect } from "react"; + +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + + useEffect(() => { + if (isPlaying) { + console.log("Calling video.play()"); + ref.current.play(); + } else { + console.log("Calling video.pause()"); + ref.current.pause(); + } + }, []); // This causes an error + + return <video ref={ref} src={src} loop playsInline />; +} + +export default function App() { + const [isPlaying, setIsPlaying] = useState(false); + const [text, setText] = useState(""); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={() => setIsPlaying(!isPlaying)}> + {isPlaying ? "Pause" : "Play"} + </button> + <VideoPlayer + isPlaying={isPlaying} + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + /> + </> + ); +} +``` + +```css +input, +button { + display: block; + margin-bottom: 20px; +} +video { + width: 250px; +} +``` + +The problem is that the code inside of your Effect _depends on_ the `isPlaying` prop to decide what to do, but this dependency was not explicitly declared. To fix this issue, add `isPlaying` to the dependency array: + +```js +useEffect(() => { + if (isPlaying) { + // It's used here... + // ... + } else { + // ... + } +}, [isPlaying]); // ...so it must be declared here! +``` + +Now all dependencies are declared, so there is no error. Specifying `[isPlaying]` as the dependency array tells React that it should skip re-running your Effect if `isPlaying` is the same as it was during the previous render. With this change, typing into the input doesn't cause the Effect to re-run, but pressing Play/Pause does: + +```js +import { useState, useRef, useEffect } from "react"; + +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + + useEffect(() => { + if (isPlaying) { + console.log("Calling video.play()"); + ref.current.play(); + } else { + console.log("Calling video.pause()"); + ref.current.pause(); + } + }, [isPlaying]); + + return <video ref={ref} src={src} loop playsInline />; +} + +export default function App() { + const [isPlaying, setIsPlaying] = useState(false); + const [text, setText] = useState(""); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={() => setIsPlaying(!isPlaying)}> + {isPlaying ? "Pause" : "Play"} + </button> + <VideoPlayer + isPlaying={isPlaying} + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + /> + </> + ); +} +``` + +```css +input, +button { + display: block; + margin-bottom: 20px; +} +video { + width: 250px; +} +``` + +The dependency array can contain multiple dependencies. React will only skip re-running the Effect if _all_ of the dependencies you specify have exactly the same values as they had during the previous render. React compares the dependency values using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. See the [`useEffect` reference](/reference/react/useEffect#reference) for details. + +**Notice that you can't "choose" your dependencies.** You will get a lint error if the dependencies you specified don't match what React expects based on the code inside your Effect. This helps catch many bugs in your code. If you don't want some code to re-run, [_edit the Effect code itself_ to not "need" that dependency.](/learn/lifecycle-of-reactive-effects#what-to-do-when-you-dont-want-to-re-synchronize) + +<Pitfall> + +The behaviors without the dependency array and with an _empty_ `[]` dependency array are different: + +```js +useEffect(() => { + // This runs after every render +}); + +useEffect(() => { + // This runs only on mount (when the component appears) +}, []); + +useEffect(() => { + // This runs on mount *and also* if either a or b have changed since the last render +}, [a, b]); +``` + +We'll take a close look at what "mount" means in the next step. + +</Pitfall> + +<DeepDive> + +#### Why was the ref omitted from the dependency array? + +This Effect uses _both_ `ref` and `isPlaying`, but only `isPlaying` is declared as a dependency: + +```js +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + useEffect(() => { + if (isPlaying) { + ref.current.play(); + } else { + ref.current.pause(); + } + }, [isPlaying]); +``` + +This is because the `ref` object has a _stable identity:_ React guarantees [you'll always get the same object](/reference/react/useRef#returns) from the same `useRef` call on every render. It never changes, so it will never by itself cause the Effect to re-run. Therefore, it does not matter whether you include it or not. Including it is fine too: + +```js +function VideoPlayer({ src, isPlaying }) { + const ref = useRef(null); + useEffect(() => { + if (isPlaying) { + ref.current.play(); + } else { + ref.current.pause(); + } + }, [isPlaying, ref]); +``` + +The [`set` functions](/reference/react/useState#setstate) returned by `useState` also have stable identity, so you will often see them omitted from the dependencies too. If the linter lets you omit a dependency without errors, it is safe to do. + +Omitting always-stable dependencies only works when the linter can "see" that the object is stable. For example, if `ref` was passed from a parent component, you would have to specify it in the dependency array. However, this is good because you can't know whether the parent component always passes the same ref, or passes one of several refs conditionally. So your Effect _would_ depend on which ref is passed. + +</DeepDive> + +### Step 3: Add cleanup if needed + +Consider a different example. You're writing a `ChatRoom` component that needs to connect to the chat server when it appears. You are given a `createConnection()` API that returns an object with `connect()` and `disconnect()` methods. How do you keep the component connected while it is displayed to the user? + +Start by writing the Effect logic: + +```js +useEffect(() => { + const connection = createConnection(); + connection.connect(); +}); +``` + +It would be slow to connect to the chat after every re-render, so you add the dependency array: + +```js +useEffect(() => { + const connection = createConnection(); + connection.connect(); +}, []); +``` + +**The code inside the Effect does not use any props or state, so your dependency array is `[]` (empty). This tells React to only run this code when the component "mounts", i.e. appears on the screen for the first time.** + +Let's try running this code: + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export default function ChatRoom() { + useEffect(() => { + const connection = createConnection(); + connection.connect(); + }, []); + return <h1>Welcome to the chat!</h1>; +} +``` + +```js +export function createConnection() { + // A real implementation would actually connect to the server + return { + connect() { + console.log("✅ Connecting..."); + }, + disconnect() { + console.log("❌ Disconnected."); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +``` + +This Effect only runs on mount, so you might expect `"✅ Connecting..."` to be printed once in the console. **However, if you check the console, `"✅ Connecting..."` gets printed twice. Why does it happen?** + +Imagine the `ChatRoom` component is a part of a larger app with many different screens. The user starts their journey on the `ChatRoom` page. The component mounts and calls `connection.connect()`. Then imagine the user navigates to another screen--for example, to the Settings page. The `ChatRoom` component unmounts. Finally, the user clicks Back and `ChatRoom` mounts again. This would set up a second connection--but the first connection was never destroyed! As the user navigates across the app, the connections would keep piling up. + +Bugs like this are easy to miss without extensive manual testing. To help you spot them quickly, in development React remounts every component once immediately after its initial mount. + +Seeing the `"✅ Connecting..."` log twice helps you notice the real issue: your code doesn't close the connection when the component unmounts. + +To fix the issue, return a _cleanup function_ from your Effect: + +```js +useEffect(() => { + const connection = createConnection(); + connection.connect(); + return () => { + connection.disconnect(); + }; +}, []); +``` + +React will call your cleanup function each time before the Effect runs again, and one final time when the component unmounts (gets removed). Let's see what happens when the cleanup function is implemented: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export default function ChatRoom() { + useEffect(() => { + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); + }, []); + return <h1>Welcome to the chat!</h1>; +} +``` + +```js +export function createConnection() { + // A real implementation would actually connect to the server + return { + connect() { + console.log("✅ Connecting..."); + }, + disconnect() { + console.log("❌ Disconnected."); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +``` + +Now you get three console logs in development: + +1. `"✅ Connecting..."` +2. `"❌ Disconnected."` +3. `"✅ Connecting..."` + +**This is the correct behavior in development.** By remounting your component, React verifies that navigating away and back would not break your code. Disconnecting and then connecting again is exactly what should happen! When you implement the cleanup well, there should be no user-visible difference between running the Effect once vs running it, cleaning it up, and running it again. There's an extra connect/disconnect call pair because React is probing your code for bugs in development. This is normal--don't try to make it go away! + +**In production, you would only see `"✅ Connecting..."` printed once.** Remounting components only happens in development to help you find Effects that need cleanup. You can turn off [Strict Mode](/reference/react/StrictMode) to opt out of the development behavior, but we recommend keeping it on. This lets you find many bugs like the one above. + +## How to handle the Effect firing twice in development? + +React intentionally remounts your components in development to find bugs like in the last example. **The right question isn't "how to run an Effect once", but "how to fix my Effect so that it works after remounting".** + +Usually, the answer is to implement the cleanup function. The cleanup function should stop or undo whatever the Effect was doing. The rule of thumb is that the user shouldn't be able to distinguish between the Effect running once (as in production) and a _setup → cleanup → setup_ sequence (as you'd see in development). + +Most of the Effects you'll write will fit into one of the common patterns below. + +### Controlling non-React widgets + +Sometimes you need to add UI widgets that aren't written to React. For example, let's say you're adding a map component to your page. It has a `setZoomLevel()` method, and you'd like to keep the zoom level in sync with a `zoomLevel` state variable in your React code. Your Effect would look similar to this: + +```js +useEffect(() => { + const map = mapRef.current; + map.setZoomLevel(zoomLevel); +}, [zoomLevel]); +``` + +Note that there is no cleanup needed in this case. In development, React will call the Effect twice, but this is not a problem because calling `setZoomLevel` twice with the same value does not do anything. It may be slightly slower, but this doesn't matter because it won't remount needlessly in production. + +Some APIs may not allow you to call them twice in a row. For example, the [`showModal`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) method of the built-in [`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement) element throws if you call it twice. Implement the cleanup function and make it close the dialog: + +```js +useEffect(() => { + const dialog = dialogRef.current; + dialog.showModal(); + return () => dialog.close(); +}, []); +``` + +In development, your Effect will call `showModal()`, then immediately `close()`, and then `showModal()` again. This has the same user-visible behavior as calling `showModal()` once, as you would see in production. + +### Subscribing to events + +If your Effect subscribes to something, the cleanup function should unsubscribe: + +```js +useEffect(() => { + function handleScroll(e) { + console.log(window.scrollX, window.scrollY); + } + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); +}, []); +``` + +In development, your Effect will call `addEventListener()`, then immediately `removeEventListener()`, and then `addEventListener()` again with the same handler. So there would be only one active subscription at a time. This has the same user-visible behavior as calling `addEventListener()` once, as in production. + +### Triggering animations + +If your Effect animates something in, the cleanup function should reset the animation to the initial values: + +```js +useEffect(() => { + const node = ref.current; + node.style.opacity = 1; // Trigger the animation + return () => { + node.style.opacity = 0; // Reset to the initial value + }; +}, []); +``` + +In development, opacity will be set to `1`, then to `0`, and then to `1` again. This should have the same user-visible behavior as setting it to `1` directly, which is what would happen in production. If you use a third-party animation library with support for tweening, your cleanup function should reset the timeline to its initial state. + +### Fetching data + +If your Effect fetches something, the cleanup function should either [abort the fetch](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) or ignore its result: + +```js +useEffect(() => { + let ignore = false; + + async function startFetching() { + const json = await fetchTodos(userId); + if (!ignore) { + setTodos(json); + } + } + + startFetching(); + + return () => { + ignore = true; + }; +}, [userId]); +``` + +You can't "undo" a network request that already happened, but your cleanup function should ensure that the fetch that's _not relevant anymore_ does not keep affecting your application. If the `userId` changes from `'Alice'` to `'Bob'`, cleanup ensures that the `'Alice'` response is ignored even if it arrives after `'Bob'`. + +**In development, you will see two fetches in the Network tab.** There is nothing wrong with that. With the approach above, the first Effect will immediately get cleaned up so its copy of the `ignore` variable will be set to `true`. So even though there is an extra request, it won't affect the state thanks to the `if (!ignore)` check. + +**In production, there will only be one request.** If the second request in development is bothering you, the best approach is to use a solution that deduplicates requests and caches their responses between components: + +```js +function TodoList() { + const todos = useSomeDataLibrary(`/api/user/${userId}/todos`); + // ... +``` + +This will not only improve the development experience, but also make your application feel faster. For example, the user pressing the Back button won't have to wait for some data to load again because it will be cached. You can either build such a cache yourself or use one of the many alternatives to manual fetching in Effects. + +<DeepDive> + +#### What are good alternatives to data fetching in Effects? + +Writing `fetch` calls inside Effects is a [popular way to fetch data](https://www.robinwieruch.de/react-hooks-fetch-data/), especially in fully client-side apps. This is, however, a very manual approach and it has significant downsides: + +- **Effects don't run on the server.** This means that the initial server-rendered HTML will only include a loading state with no data. The client computer will have to download all JavaScript and render your app only to discover that now it needs to load the data. This is not very efficient. +- **Fetching directly in Effects makes it easy to create "network waterfalls".** You render the parent component, it fetches some data, renders the child components, and then they start fetching their data. If the network is not very fast, this is significantly slower than fetching all data in parallel. +- **Fetching directly in Effects usually means you don't preload or cache data.** For example, if the component unmounts and then mounts again, it would have to fetch the data again. +- **It's not very ergonomic.** There's quite a bit of boilerplate code involved when writing `fetch` calls in a way that doesn't suffer from bugs like [race conditions.](https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect) + +This list of downsides is not specific to React. It applies to fetching data on mount with any library. Like with routing, data fetching is not trivial to do well, so we recommend the following approaches: + +- **If you use a [framework](/learn/start-a-new-react-project#production-grade-react-frameworks), use its built-in data fetching mechanism.** Modern React frameworks have integrated data fetching mechanisms that are efficient and don't suffer from the above pitfalls. +- **Otherwise, consider using or building a client-side cache.** Popular open source solutions include [React Query](https://tanstack.com/query/latest), [useSWR](https://swr.vercel.app/), and [React Router 6.4+.](https://beta.reactrouter.com/en/main/start/overview) You can build your own solution too, in which case you would use Effects under the hood, but add logic for deduplicating requests, caching responses, and avoiding network waterfalls (by preloading data or hoisting data requirements to routes). + +You can continue fetching data directly in Effects if neither of these approaches suit you. + +</DeepDive> + +### Sending analytics + +Consider this code that sends an analytics event on the page visit: + +```js +useEffect(() => { + logVisit(url); // Sends a POST request +}, [url]); +``` + +In development, `logVisit` will be called twice for every URL, so you might be tempted to try to fix that. **We recommend keeping this code as is.** Like with earlier examples, there is no _user-visible_ behavior difference between running it once and running it twice. From a practical point of view, `logVisit` should not do anything in development because you don't want the logs from the development machines to skew the production metrics. Your component remounts every time you save its file, so it logs extra visits in development anyway. + +**In production, there will be no duplicate visit logs.** + +To debug the analytics events you're sending, you can deploy your app to a staging environment (which runs in production mode) or temporarily opt out of [Strict Mode](/reference/react/StrictMode) and its development-only remounting checks. You may also send analytics from the route change event handlers instead of Effects. For more precise analytics, [intersection observers](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) can help track which components are in the viewport and how long they remain visible. + +### Not an Effect: Initializing the application + +Some logic should only run once when the application starts. You can put it outside your components: + +```js +if (typeof window !== "undefined") { + // Check if we're running in the browser. + checkAuthToken(); + loadDataFromLocalStorage(); +} + +function App() { + // ... +} +``` + +This guarantees that such logic only runs once after the browser loads the page. + +### Not an Effect: Buying a product + +Sometimes, even if you write a cleanup function, there's no way to prevent user-visible consequences of running the Effect twice. For example, maybe your Effect sends a POST request like buying a product: + +```js +useEffect(() => { + // 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code. + fetch("/api/buy", { method: "POST" }); +}, []); +``` + +You wouldn't want to buy the product twice. However, this is also why you shouldn't put this logic in an Effect. What if the user goes to another page and then presses Back? Your Effect would run again. You don't want to buy the product when the user _visits_ a page; you want to buy it when the user _clicks_ the Buy button. + +Buying is not caused by rendering; it's caused by a specific interaction. It should run only when the user presses the button. **Delete the Effect and move your `/api/buy` request into the Buy button event handler:** + +```js +function handleClick() { + // ✅ Buying is an event because it is caused by a particular interaction. + fetch("/api/buy", { method: "POST" }); +} +``` + +**This illustrates that if remounting breaks the logic of your application, this usually uncovers existing bugs.** From the user's perspective, visiting a page shouldn't be different from visiting it, clicking a link, and pressing Back. React verifies that your components abide by this principle by remounting them once in development. + +## Putting it all together + +This playground can help you "get a feel" for how Effects work in practice. + +This example uses [`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) to schedule a console log with the input text to appear three seconds after the Effect runs. The cleanup function cancels the pending timeout. Start by pressing "Mount the component": + +```js +import { useState, useEffect } from "react"; + +function Playground() { + const [text, setText] = useState("a"); + + useEffect(() => { + function onTimeout() { + console.log("⏰ " + text); + } + + console.log('🔵 Schedule "' + text + '" log'); + const timeoutId = setTimeout(onTimeout, 3000); + + return () => { + console.log('🟡 Cancel "' + text + '" log'); + clearTimeout(timeoutId); + }; + }, [text]); + + return ( + <> + <label> + What to log:{" "} + <input value={text} onChange={(e) => setText(e.target.value)} /> + </label> + <h1>{text}</h1> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow(!show)}> + {show ? "Unmount" : "Mount"} the component + </button> + {show && <hr />} + {show && <Playground />} + </> + ); +} +``` + +You will see three logs at first: `Schedule "a" log`, `Cancel "a" log`, and `Schedule "a" log` again. Three second later there will also be a log saying `a`. As you learned earlier, the extra schedule/cancel pair is because React remounts the component once in development to verify that you've implemented cleanup well. + +Now edit the input to say `abc`. If you do it fast enough, you'll see `Schedule "ab" log` immediately followed by `Cancel "ab" log` and `Schedule "abc" log`. **React always cleans up the previous render's Effect before the next render's Effect.** This is why even if you type into the input fast, there is at most one timeout scheduled at a time. Edit the input a few times and watch the console to get a feel for how Effects get cleaned up. + +Type something into the input and then immediately press "Unmount the component". Notice how unmounting cleans up the last render's Effect. Here, it clears the last timeout before it has a chance to fire. + +Finally, edit the component above and comment out the cleanup function so that the timeouts don't get cancelled. Try typing `abcde` fast. What do you expect to happen in three seconds? Will `console.log(text)` inside the timeout print the _latest_ `text` and produce five `abcde` logs? Give it a try to check your intuition! + +Three seconds later, you should see a sequence of logs (`a`, `ab`, `abc`, `abcd`, and `abcde`) rather than five `abcde` logs. **Each Effect "captures" the `text` value from its corresponding render.** It doesn't matter that the `text` state changed: an Effect from the render with `text = 'ab'` will always see `'ab'`. In other words, Effects from each render are isolated from each other. If you're curious how this works, you can read about [closures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures). + +<DeepDive> + +#### Each render has its own Effects + +You can think of `useEffect` as "attaching" a piece of behavior to the render output. Consider this Effect: + +```js +export default function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return <h1>Welcome to {roomId}!</h1>; +} +``` + +Let's see what exactly happens as the user navigates around the app. + +#### Initial render + +The user visits `<ChatRoom roomId="general" />`. Let's [mentally substitute](/learn/state-as-a-snapshot#rendering-takes-a-snapshot-in-time) `roomId` with `'general'`: + +```js +// JSX for the first render (roomId = "general") +return <h1>Welcome to general!</h1>; +``` + +**The Effect is _also_ a part of the rendering output.** The first render's Effect becomes: + +```js +// Effect for the first render (roomId = "general") +() => { + const connection = createConnection("general"); + connection.connect(); + return () => connection.disconnect(); +}, + // Dependencies for the first render (roomId = "general") + ["general"]; +``` + +React runs this Effect, which connects to the `'general'` chat room. + +#### Re-render with same dependencies + +Let's say `<ChatRoom roomId="general" />` re-renders. The JSX output is the same: + +```js +// JSX for the second render (roomId = "general") +return <h1>Welcome to general!</h1>; +``` + +React sees that the rendering output has not changed, so it doesn't update the DOM. + +The Effect from the second render looks like this: + +```js +// Effect for the second render (roomId = "general") +() => { + const connection = createConnection("general"); + connection.connect(); + return () => connection.disconnect(); +}, + // Dependencies for the second render (roomId = "general") + ["general"]; +``` + +React compares `['general']` from the second render with `['general']` from the first render. **Because all dependencies are the same, React _ignores_ the Effect from the second render.** It never gets called. + +#### Re-render with different dependencies + +Then, the user visits `<ChatRoom roomId="travel" />`. This time, the component returns different JSX: + +```js +// JSX for the third render (roomId = "travel") +return <h1>Welcome to travel!</h1>; +``` + +React updates the DOM to change `"Welcome to general"` into `"Welcome to travel"`. + +The Effect from the third render looks like this: + +```js +// Effect for the third render (roomId = "travel") +() => { + const connection = createConnection("travel"); + connection.connect(); + return () => connection.disconnect(); +}, + // Dependencies for the third render (roomId = "travel") + ["travel"]; +``` + +React compares `['travel']` from the third render with `['general']` from the second render. One dependency is different: `Object.is('travel', 'general')` is `false`. The Effect can't be skipped. + +**Before React can apply the Effect from the third render, it needs to clean up the last Effect that _did_ run.** The second render's Effect was skipped, so React needs to clean up the first render's Effect. If you scroll up to the first render, you'll see that its cleanup calls `disconnect()` on the connection that was created with `createConnection('general')`. This disconnects the app from the `'general'` chat room. + +After that, React runs the third render's Effect. It connects to the `'travel'` chat room. + +#### Unmount + +Finally, let's say the user navigates away, and the `ChatRoom` component unmounts. React runs the last Effect's cleanup function. The last Effect was from the third render. The third render's cleanup destroys the `createConnection('travel')` connection. So the app disconnects from the `'travel'` room. + +#### Development-only behaviors + +When [Strict Mode](/reference/react/StrictMode) is on, React remounts every component once after mount (state and DOM are preserved). This [helps you find Effects that need cleanup](#step-3-add-cleanup-if-needed) and exposes bugs like race conditions early. Additionally, React will remount the Effects whenever you save a file in development. Both of these behaviors are development-only. + +</DeepDive> + +<Recap> + +- Unlike events, Effects are caused by rendering itself rather than a particular interaction. +- Effects let you synchronize a component with some external system (third-party API, network, etc). +- By default, Effects run after every render (including the initial one). +- React will skip the Effect if all of its dependencies have the same values as during the last render. +- You can't "choose" your dependencies. They are determined by the code inside the Effect. +- Empty dependency array (`[]`) corresponds to the component "mounting", i.e. being added to the screen. +- In Strict Mode, React mounts components twice (in development only!) to stress-test your Effects. +- If your Effect breaks because of remounting, you need to implement a cleanup function. +- React will call your cleanup function before the Effect runs next time, and during the unmount. + +</Recap> + +<Challenges> + +#### Focus a field on mount + +In this example, the form renders a `<MyInput />` component. + +Use the input's [`focus()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus) method to make `MyInput` automatically focus when it appears on the screen. There is already a commented out implementation, but it doesn't quite work. Figure out why it doesn't work, and fix it. (If you're familiar with the `autoFocus` attribute, pretend that it does not exist: we are reimplementing the same functionality from scratch.) + +```js +import { useEffect, useRef } from "react"; + +export default function MyInput({ value, onChange }) { + const ref = useRef(null); + + // TODO: This doesn't quite work. Fix it. + // ref.current.focus() + + return <input ref={ref} value={value} onChange={onChange} />; +} +``` + +```js +import { useState } from "react"; +import MyInput from "./MyInput.js"; + +export default function Form() { + const [show, setShow] = useState(false); + const [name, setName] = useState("Taylor"); + const [upper, setUpper] = useState(false); + return ( + <> + <button on_click={() => setShow((s) => !s)}> + {show ? "Hide" : "Show"} form + </button> + <br /> + <hr /> + {show && ( + <> + <label> + Enter your name: + <MyInput + value={name} + onChange={(e) => setName(e.target.value)} + /> + </label> + <label> + <input + type="checkbox" + checked={upper} + onChange={(e) => setUpper(e.target.checked)} + /> + Make it uppercase + </label> + <p> + Hello, <b>{upper ? name.toUpperCase() : name}</b> + </p> + </> + )} + </> + ); +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +To verify that your solution works, press "Show form" and verify that the input receives focus (becomes highlighted and the cursor is placed inside). Press "Hide form" and "Show form" again. Verify the input is highlighted again. + +`MyInput` should only focus _on mount_ rather than after every render. To verify that the behavior is right, press "Show form" and then repeatedly press the "Make it uppercase" checkbox. Clicking the checkbox should _not_ focus the input above it. + +<Solution> + +Calling `ref.current.focus()` during render is wrong because it is a _side effect_. Side effects should either be placed inside an event handler or be declared with `useEffect`. In this case, the side effect is _caused_ by the component appearing rather than by any specific interaction, so it makes sense to put it in an Effect. + +To fix the mistake, wrap the `ref.current.focus()` call into an Effect declaration. Then, to ensure that this Effect runs only on mount rather than after every render, add the empty `[]` dependencies to it. + +```js +import { useEffect, useRef } from "react"; + +export default function MyInput({ value, onChange }) { + const ref = useRef(null); + + useEffect(() => { + ref.current.focus(); + }, []); + + return <input ref={ref} value={value} onChange={onChange} />; +} +``` + +```js +import { useState } from "react"; +import MyInput from "./MyInput.js"; + +export default function Form() { + const [show, setShow] = useState(false); + const [name, setName] = useState("Taylor"); + const [upper, setUpper] = useState(false); + return ( + <> + <button on_click={() => setShow((s) => !s)}> + {show ? "Hide" : "Show"} form + </button> + <br /> + <hr /> + {show && ( + <> + <label> + Enter your name: + <MyInput + value={name} + onChange={(e) => setName(e.target.value)} + /> + </label> + <label> + <input + type="checkbox" + checked={upper} + onChange={(e) => setUpper(e.target.checked)} + /> + Make it uppercase + </label> + <p> + Hello, <b>{upper ? name.toUpperCase() : name}</b> + </p> + </> + )} + </> + ); +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +</Solution> + +#### Focus a field conditionally + +This form renders two `<MyInput />` components. + +Press "Show form" and notice that the second field automatically gets focused. This is because both of the `<MyInput />` components try to focus the field inside. When you call `focus()` for two input fields in a row, the last one always "wins". + +Let's say you want to focus the first field. The first `MyInput` component now receives a boolean `shouldFocus` prop set to `true`. Change the logic so that `focus()` is only called if the `shouldFocus` prop received by `MyInput` is `true`. + +```js +import { useEffect, useRef } from "react"; + +export default function MyInput({ shouldFocus, value, onChange }) { + const ref = useRef(null); + + // TODO: call focus() only if shouldFocus is true. + useEffect(() => { + ref.current.focus(); + }, []); + + return <input ref={ref} value={value} onChange={onChange} />; +} +``` + +```js +import { useState } from "react"; +import MyInput from "./MyInput.js"; + +export default function Form() { + const [show, setShow] = useState(false); + const [firstName, setFirstName] = useState("Taylor"); + const [lastName, setLastName] = useState("Swift"); + const [upper, setUpper] = useState(false); + const name = firstName + " " + lastName; + return ( + <> + <button on_click={() => setShow((s) => !s)}> + {show ? "Hide" : "Show"} form + </button> + <br /> + <hr /> + {show && ( + <> + <label> + Enter your first name: + <MyInput + value={firstName} + onChange={(e) => setFirstName(e.target.value)} + shouldFocus={true} + /> + </label> + <label> + Enter your last name: + <MyInput + value={lastName} + onChange={(e) => setLastName(e.target.value)} + shouldFocus={false} + /> + </label> + <p> + Hello, <b>{upper ? name.toUpperCase() : name}</b> + </p> + </> + )} + </> + ); +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +To verify your solution, press "Show form" and "Hide form" repeatedly. When the form appears, only the _first_ input should get focused. This is because the parent component renders the first input with `shouldFocus={true}` and the second input with `shouldFocus={false}`. Also check that both inputs still work and you can type into both of them. + +<Hint> + +You can't declare an Effect conditionally, but your Effect can include conditional logic. + +</Hint> + +<Solution> + +Put the conditional logic inside the Effect. You will need to specify `shouldFocus` as a dependency because you are using it inside the Effect. (This means that if some input's `shouldFocus` changes from `false` to `true`, it will focus after mount.) + +```js +import { useEffect, useRef } from "react"; + +export default function MyInput({ shouldFocus, value, onChange }) { + const ref = useRef(null); + + useEffect(() => { + if (shouldFocus) { + ref.current.focus(); + } + }, [shouldFocus]); + + return <input ref={ref} value={value} onChange={onChange} />; +} +``` + +```js +import { useState } from "react"; +import MyInput from "./MyInput.js"; + +export default function Form() { + const [show, setShow] = useState(false); + const [firstName, setFirstName] = useState("Taylor"); + const [lastName, setLastName] = useState("Swift"); + const [upper, setUpper] = useState(false); + const name = firstName + " " + lastName; + return ( + <> + <button on_click={() => setShow((s) => !s)}> + {show ? "Hide" : "Show"} form + </button> + <br /> + <hr /> + {show && ( + <> + <label> + Enter your first name: + <MyInput + value={firstName} + onChange={(e) => setFirstName(e.target.value)} + shouldFocus={true} + /> + </label> + <label> + Enter your last name: + <MyInput + value={lastName} + onChange={(e) => setLastName(e.target.value)} + shouldFocus={false} + /> + </label> + <p> + Hello, <b>{upper ? name.toUpperCase() : name}</b> + </p> + </> + )} + </> + ); +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +</Solution> + +#### Fix an interval that fires twice + +This `Counter` component displays a counter that should increment every second. On mount, it calls [`setInterval`.](https://developer.mozilla.org/en-US/docs/Web/API/setInterval) This causes `onTick` to run every second. The `onTick` function increments the counter. + +However, instead of incrementing once per second, it increments twice. Why is that? Find the cause of the bug and fix it. + +<Hint> + +Keep in mind that `setInterval` returns an interval ID, which you can pass to [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) to stop the interval. + +</Hint> + +```js +import { useState, useEffect } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + function onTick() { + setCount((c) => c + 1); + } + + setInterval(onTick, 1000); + }, []); + + return <h1>{count}</h1>; +} +``` + +```js +import { useState } from "react"; +import Counter from "./Counter.js"; + +export default function Form() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow((s) => !s)}> + {show ? "Hide" : "Show"} counter + </button> + <br /> + <hr /> + {show && <Counter />} + </> + ); +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +<Solution> + +When [Strict Mode](/reference/react/StrictMode) is on (like in the sandboxes on this site), React remounts each component once in development. This causes the interval to be set up twice, and this is why each second the counter increments twice. + +However, React's behavior is not the _cause_ of the bug: the bug already exists in the code. React's behavior makes the bug more noticeable. The real cause is that this Effect starts a process but doesn't provide a way to clean it up. + +To fix this code, save the interval ID returned by `setInterval`, and implement a cleanup function with [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval): + +```js +import { useState, useEffect } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + function onTick() { + setCount((c) => c + 1); + } + + const intervalId = setInterval(onTick, 1000); + return () => clearInterval(intervalId); + }, []); + + return <h1>{count}</h1>; +} +``` + +```js +import { useState } from "react"; +import Counter from "./Counter.js"; + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button on_click={() => setShow((s) => !s)}> + {show ? "Hide" : "Show"} counter + </button> + <br /> + <hr /> + {show && <Counter />} + </> + ); +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +In development, React will still remount your component once to verify that you've implemented cleanup well. So there will be a `setInterval` call, immediately followed by `clearInterval`, and `setInterval` again. In production, there will be only one `setInterval` call. The user-visible behavior in both cases is the same: the counter increments once per second. + +</Solution> + +#### Fix fetching inside an Effect + +This component shows the biography for the selected person. It loads the biography by calling an asynchronous function `fetchBio(person)` on mount and whenever `person` changes. That asynchronous function returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) which eventually resolves to a string. When fetching is done, it calls `setBio` to display that string under the select box. + +```js +import { useState, useEffect } from "react"; +import { fetchBio } from "./api.js"; + +export default function Page() { + const [person, setPerson] = useState("Alice"); + const [bio, setBio] = useState(null); + + useEffect(() => { + setBio(null); + fetchBio(person).then((result) => { + setBio(result); + }); + }, [person]); + + return ( + <> + <select + value={person} + onChange={(e) => { + setPerson(e.target.value); + }} + > + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + <option value="Taylor">Taylor</option> + </select> + <hr /> + <p> + <i>{bio ?? "Loading..."}</i> + </p> + </> + ); +} +``` + +```js +export async function fetchBio(person) { + const delay = person === "Bob" ? 2000 : 200; + return new Promise((resolve) => { + setTimeout(() => { + resolve("This is " + person + "’s bio."); + }, delay); + }); +} +``` + +There is a bug in this code. Start by selecting "Alice". Then select "Bob" and then immediately after that select "Taylor". If you do this fast enough, you will notice that bug: Taylor is selected, but the paragraph below says "This is Bob's bio." + +Why does this happen? Fix the bug inside this Effect. + +<Hint> + +If an Effect fetches something asynchronously, it usually needs cleanup. + +</Hint> + +<Solution> + +To trigger the bug, things need to happen in this order: + +- Selecting `'Bob'` triggers `fetchBio('Bob')` +- Selecting `'Taylor'` triggers `fetchBio('Taylor')` +- **Fetching `'Taylor'` completes _before_ fetching `'Bob'`** +- The Effect from the `'Taylor'` render calls `setBio('This is Taylor’s bio')` +- Fetching `'Bob'` completes +- The Effect from the `'Bob'` render calls `setBio('This is Bob’s bio')` + +This is why you see Bob's bio even though Taylor is selected. Bugs like this are called [race conditions](https://en.wikipedia.org/wiki/Race_condition) because two asynchronous operations are "racing" with each other, and they might arrive in an unexpected order. + +To fix this race condition, add a cleanup function: + +```js +import { useState, useEffect } from "react"; +import { fetchBio } from "./api.js"; + +export default function Page() { + const [person, setPerson] = useState("Alice"); + const [bio, setBio] = useState(null); + useEffect(() => { + let ignore = false; + setBio(null); + fetchBio(person).then((result) => { + if (!ignore) { + setBio(result); + } + }); + return () => { + ignore = true; + }; + }, [person]); + + return ( + <> + <select + value={person} + onChange={(e) => { + setPerson(e.target.value); + }} + > + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + <option value="Taylor">Taylor</option> + </select> + <hr /> + <p> + <i>{bio ?? "Loading..."}</i> + </p> + </> + ); +} +``` + +```js +export async function fetchBio(person) { + const delay = person === "Bob" ? 2000 : 200; + return new Promise((resolve) => { + setTimeout(() => { + resolve("This is " + person + "’s bio."); + }, delay); + }); +} +``` + +Each render's Effect has its own `ignore` variable. Initially, the `ignore` variable is set to `false`. However, if an Effect gets cleaned up (such as when you select a different person), its `ignore` variable becomes `true`. So now it doesn't matter in which order the requests complete. Only the last person's Effect will have `ignore` set to `false`, so it will call `setBio(result)`. Past Effects have been cleaned up, so the `if (!ignore)` check will prevent them from calling `setBio`: + +- Selecting `'Bob'` triggers `fetchBio('Bob')` +- Selecting `'Taylor'` triggers `fetchBio('Taylor')` **and cleans up the previous (Bob's) Effect** +- Fetching `'Taylor'` completes _before_ fetching `'Bob'` +- The Effect from the `'Taylor'` render calls `setBio('This is Taylor’s bio')` +- Fetching `'Bob'` completes +- The Effect from the `'Bob'` render **does not do anything because its `ignore` flag was set to `true`** + +In addition to ignoring the result of an outdated API call, you can also use [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) to cancel the requests that are no longer needed. However, by itself this is not enough to protect against race conditions. More asynchronous steps could be chained after the fetch, so using an explicit flag like `ignore` is the most reliable way to fix this type of problems. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/thinking-in-react.md b/docs/src/learn/thinking-in-react.md new file mode 100644 index 000000000..57873633e --- /dev/null +++ b/docs/src/learn/thinking-in-react.md @@ -0,0 +1,245 @@ +## Overview + +<p class="intro" markdown> + +React can change how you think about the designs you look at and the apps you build. When you build a user interface with React, you will first break it apart into pieces called _components_. Then, you will describe the different visual states for each of your components. Finally, you will connect your components together so that the data flows through them. In this tutorial, we’ll guide you through the thought process of building a searchable product data table with React. + +</p> + +## Start with the mockup + +Imagine that you already have a JSON API and a mockup from a designer. + +The JSON API returns some data that looks like this: + +```json linenums="0" +{% include "../../examples/thinking_in_react/start_with_the_mockup.json" %} +``` + +The mockup looks like this: + +<img src="../../assets/images/s_thinking-in-react_ui.png" width="300" style="margin: 0 auto" /> + +To implement a UI in React, you will usually follow the same five steps. + +## Step 1: Break the UI into a component hierarchy + +Start by drawing boxes around every component and subcomponent in the mockup and naming them. If you work with a designer, they may have already named these components in their design tool. Ask them! + +Depending on your background, you can think about splitting up a design into components in different ways: + +- **Programming**--use the same techniques for deciding if you should create a new function or object. One such technique is the [single responsibility principle](https://en.wikipedia.org/wiki/Single_responsibility_principle), that is, a component should ideally only do one thing. If it ends up growing, it should be decomposed into smaller subcomponents. +- **CSS**--consider what you would make class selectors for. (However, components are a bit less granular.) +- **Design**--consider how you would organize the design's layers. + +If your JSON is well-structured, you'll often find that it naturally maps to the component structure of your UI. That's because UI and data models often have the same information architecture--that is, the same shape. Separate your UI into components, where each component matches one piece of your data model. + +There are five components on this screen: + +<!-- TODO: Change this image to use snake_case --> + +<img src="../../assets/images/s_thinking-in-react_ui_outline.png" width="500" style="margin: 0 auto" /> + +1. `filterable_product_table` (grey) contains the entire app. +2. `search_bar` (blue) receives the user input. +3. `product_table` (lavender) displays and filters the list according to the user input. +4. `product_category_row` (green) displays a heading for each category. +5. `product_row` (yellow) displays a row for each product. + +If you look at `product_table` (lavender), you'll see that the table header (containing the "Name" and "Price" labels) isn't its own component. This is a matter of preference, and you could go either way. For this example, it is a part of `product_table` because it appears inside the `product_table`'s list. However, if this header grows to be complex (e.g., if you add sorting), you can move it into its own `product_table_header` component. + +Now that you've identified the components in the mockup, arrange them into a hierarchy. Components that appear within another component in the mockup should appear as a child in the hierarchy: + +- `filterable_product_table` + - `search_bar` + - `product_table` + - `product_category_row` + - `product_row` + +## Step 2: Build a static version in React + +Now that you have your component hierarchy, it's time to implement your app. The most straightforward approach is to build a version that renders the UI from your data model without adding any interactivity... yet! It's often easier to build the static version first and add interactivity later. Building a static version requires a lot of typing and no thinking, but adding interactivity requires a lot of thinking and not a lot of typing. + +To build a static version of your app that renders your data model, you'll want to build [components](your-first-component.md) that reuse other components and pass data using [props.](../learn/passing-props-to-a-component.md) Props are a way of passing data from parent to child. (If you're familiar with the concept of [state](../learn/state-a-components-memory.md), don't use state at all to build this static version. State is reserved only for interactivity, that is, data that changes over time. Since this is a static version of the app, you don't need it.) + +You can either build "top down" by starting with building the components higher up in the hierarchy (like `filterable_product_table`) or "bottom up" by working from components lower down (like `product_row`). In simpler examples, it’s usually easier to go top-down, and on larger projects, it’s easier to go bottom-up. + +=== "app.py" + + ```python + {% include "../../examples/thinking_in_react/build_a_static_version_in_react.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/thinking_in_react/build_a_static_version_in_react.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +(If this code looks intimidating, go through the [Quick Start](../learn/quick-start.md) first!) + +After building your components, you'll have a library of reusable components that render your data model. Because this is a static app, the components will only return non-interactive HTML. The component at the top of the hierarchy (`filterable_product_table`) will take your data model as a prop. This is called _one-way data flow_ because the data flows down from the top-level component to the ones at the bottom of the tree. + +!!! warning "Pitfall" + + At this point, you should not be using any state values. That’s for the next step! + +## Step 3: Find the minimal but complete representation of UI state + +To make the UI interactive, you need to let users change your underlying data model. You will use _state_ for this. + +Think of state as the minimal set of changing data that your app needs to remember. The most important principle for structuring state is to keep it [DRY (Don't Repeat Yourself).](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) Figure out the absolute minimal representation of the state your application needs and compute everything else on-demand. For example, if you're building a shopping list, you can store the items as an array in state. If you want to also display the number of items in the list, don't store the number of items as another state value--instead, read the length of your array. + +Now think of all of the pieces of data in this example application: + +1. The original list of products +2. The search text the user has entered +3. The value of the checkbox +4. The filtered list of products + +Which of these are state? Identify the ones that are not: + +- Does it **remain unchanged** over time? If so, it isn't state. +- Is it **passed in from a parent** via props? If so, it isn't state. +- **Can you compute it** based on existing state or props in your component? If so, it _definitely_ isn't state! + +What's left is probably state. + +Let's go through them one by one again: + +1. The original list of products is **passed in as props, so it's not state.** +2. The search text seems to be state since it changes over time and can't be computed from anything. +3. The value of the checkbox seems to be state since it changes over time and can't be computed from anything. +4. The filtered list of products **isn't state because it can be computed** by taking the original list of products and filtering it according to the search text and value of the checkbox. + +This means only the search text and the value of the checkbox are state! Nicely done! + +!!! info "Deep Dive" + + <font size="4">**Props vs State**</font> + + There are two types of "model" data in React: props and state. The two are very different: + + - [**Props** are like arguments you pass](../learn/passing-props-to-a-component.md) to a function. They let a parent component pass data to a child component and customize its appearance. For example, a `html.form` can pass a `color` prop to a `html.button`. + - [**State** is like a component’s memory.](../learn/state-a-components-memory.md) It lets a component keep track of some information and change it in response to interactions. For example, a `html.button` might keep track of `is_hovered` state. + + Props and state are different, but they work together. A parent component will often keep some information in state (so that it can change it), and _pass it down_ to child components as their props. It's okay if the difference still feels fuzzy on the first read. It takes a bit of practice for it to really stick! + +## Step 4: Identify where your state should live + +After identifying your app’s minimal state data, you need to identify which component is responsible for changing this state, or _owns_ the state. Remember: React uses one-way data flow, passing data down the component hierarchy from parent to child component. It may not be immediately clear which component should own what state. This can be challenging if you’re new to this concept, but you can figure it out by following these steps! + +For each piece of state in your application: + +1. Identify _every_ component that renders something based on that state. +2. Find their closest common parent component—a component above them all in the hierarchy. +3. Decide where the state should live: + 1. Often, you can put the state directly into their common parent. + 2. You can also put the state into some component above their common parent. + 3. If you can't find a component where it makes sense to own the state, create a new component solely for holding the state and add it somewhere in the hierarchy above the common parent component. + +In the previous step, you found two pieces of state in this application: the search input text, and the value of the checkbox. In this example, they always appear together, so it makes sense to put them into the same place. + +Now let's run through our strategy for them: + +1. **Identify components that use state:** + - `product_table` needs to filter the product list based on that state (search text and checkbox value). + - `search_bar` needs to display that state (search text and checkbox value). +1. **Find their common parent:** The first parent component both components share is `filterable_product_table`. +1. **Decide where the state lives**: We'll keep the filter text and checked state values in `filterable_product_table`. + +So the state values will live in `filterable_product_table`. + +Add state to the component with the [`use_state()` Hook.](../reference/use-state.md) Hooks are special functions that let you "hook into" React. Add two state variables at the top of `filterable_product_table` and specify their initial state: + +```python linenums="0" +{% include "../../examples/thinking_in_react/use_state.py" start="# start" %} +``` + +Then, pass `filter_text` and `in_stock_only` to `product_table` and `search_bar` as props: + +```python linenums="0" +{% include "../../examples/thinking_in_react/use_state_with_components.py" start="# start" %} +``` + +You can start seeing how your application will behave. Edit the `filter_text` initial value from `use_state('')` to `use_state('fruit')` in the sandbox code below. You'll see both the search input text and the table update: + +=== "app.py" + + ```python + {% include "../../examples/thinking_in_react/identify_where_your_state_should_live.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/thinking_in_react/identify_where_your_state_should_live.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +Notice that editing the form doesn't work yet. + +In the code above, `product_table` and `search_bar` read the `filter_text` and `in_stock_only` props to render the table, the input, and the checkbox. For example, here is how `search_bar` populates the input value: + +```python linenums="0" hl_lines="2 7" +{% include "../../examples/thinking_in_react/error_example.py" start="# start" %} +``` + +However, you haven't added any code to respond to the user actions like typing yet. This will be your final step. + +## Step 5: Add inverse data flow + +Currently your app renders correctly with props and state flowing down the hierarchy. But to change the state according to user input, you will need to support data flowing the other way: the form components deep in the hierarchy need to update the state in `filterable_product_table`. + +React makes this data flow explicit, but it requires a little more typing than two-way data binding. If you try to type or check the box in the example above, you'll see that React ignores your input. This is intentional. By writing `<input value={filter_text} />`, you've set the `value` prop of the `input` to always be equal to the `filter_text` state passed in from `filterable_product_table`. Since `filter_text` state is never set, the input never changes. + +You want to make it so whenever the user changes the form inputs, the state updates to reflect those changes. The state is owned by `filterable_product_table`, so only it can call `set_filter_text` and `set_in_stock_only`. To let `search_bar` update the `filterable_product_table`'s state, you need to pass these functions down to `search_bar`: + +```python linenums="0" hl_lines="3-4 10-11" +{% include "../../examples/thinking_in_react/set_state_props.py" start="# start" %} +``` + +Inside the `search_bar`, you will add the `onChange` event handlers and set the parent state from them: + +```python linenums="0" hl_lines="6" +{% include "../../examples/thinking_in_react/event_handlers.py" start="# start" %} +``` + +Now the application fully works! + +=== "app.py" + + <!-- FIXME: Click event on the checkbox is broken. `event["target"]["checked"]` doesn't exist --> + + ```python + {% include "../../examples/thinking_in_react/add_inverse_data_flow.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/thinking_in_react/add_inverse_data_flow.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +You can learn all about handling events and updating state in the [Adding Interactivity](../learn/responding-to-events.md) section. + +## Where to go from here + +This was a very brief introduction to how to think about building components and applications with React. You can [start a React project](../learn/start-a-new-react-project.md) right now or [dive deeper on all the syntax](../learn/your-first-component.md) used in this tutorial. diff --git a/docs/src/learn/tutorial-material-ui.md b/docs/src/learn/tutorial-material-ui.md new file mode 100644 index 000000000..a1a5d86f9 --- /dev/null +++ b/docs/src/learn/tutorial-material-ui.md @@ -0,0 +1,5 @@ +!!! warning "Planned / Undeveloped" + + This tutorial is planned, but is missing a key feature before this page can be written. + + See [this issue](https://github.com/reactive-python/reactpy/issues/786) for more details. diff --git a/docs/src/learn/tutorial-react-bootstrap.md b/docs/src/learn/tutorial-react-bootstrap.md new file mode 100644 index 000000000..a1a5d86f9 --- /dev/null +++ b/docs/src/learn/tutorial-react-bootstrap.md @@ -0,0 +1,5 @@ +!!! warning "Planned / Undeveloped" + + This tutorial is planned, but is missing a key feature before this page can be written. + + See [this issue](https://github.com/reactive-python/reactpy/issues/786) for more details. diff --git a/docs/src/learn/tutorial-tic-tac-toe.md b/docs/src/learn/tutorial-tic-tac-toe.md new file mode 100644 index 000000000..e92f53bf2 --- /dev/null +++ b/docs/src/learn/tutorial-tic-tac-toe.md @@ -0,0 +1,2961 @@ +## Overview + +<p class="intro" markdown> + +You will build a small tic-tac-toe game during this tutorial. This tutorial does not assume any existing React knowledge. The techniques you'll learn in the tutorial are fundamental to building any React app, and fully understanding it will give you a deep understanding of React. + +</p> + +!!! abstract "Note" + + This tutorial is designed for people who prefer to **learn by doing** and want to quickly try making something tangible. If you prefer learning each concept step by step, start with [Describing the UI.](./your-first-component.md) + +The tutorial is divided into several sections: + +- [Setup for the tutorial](#setup-for-the-tutorial) will give you **a starting point** to follow the tutorial. +- [Overview](#overview) will teach you **the fundamentals** of React: components, props, and state. +- [Completing the game](#completing-the-game) will teach you **the most common techniques** in React development. +- [Adding time travel](#adding-time-travel) will give you **a deeper insight** into the unique strengths of React. + +### What are you building? + +In this tutorial, you'll build an interactive tic-tac-toe game with React. + +You can see what it will look like when you're finished here: + +=== "app.py" + + <!-- FIXME: Currently this example uses empty string instead of None, due to a bug with ReactPy --> + + ```python + {% include "../../examples/tutorial_tic_tac_toe/tic_tac_toe.py" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/tutorial_tic_tac_toe/tic_tac_toe.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +If the code doesn't make sense to you yet, or if you are unfamiliar with the code's syntax, don't worry! The goal of this tutorial is to help you understand React and its syntax. + +We recommend that you check out the tic-tac-toe game above before continuing with the tutorial. One of the features that you'll notice is that there is a numbered list to the right of the game's board. This list gives you a history of all of the moves that have occurred in the game, and it is updated as the game progresses. + +Once you've played around with the finished tic-tac-toe game, keep scrolling. You'll start with a simpler template in this tutorial. Our next step is to set you up so that you can start building the game. + +## Setup for the tutorial + +In the code example below, click **Run** to open the editor in a new tab using the website Jupyter. Jupyter lets you write code in your browser and preview how your users will see the app you've created. The new tab should display an empty square and the starter code for this tutorial. + +=== "app.py" + + ```python + {% include "../../examples/tutorial_tic_tac_toe/setup_for_the_tutorial.py" start="# start" %} + ``` + +=== "styles.css" + + ```css + {% include "../../examples/tutorial_tic_tac_toe/setup_for_the_tutorial.css" %} + ``` + +=== ":material-play: Run" + + ```python + # TODO + ``` + +!!! abstract "Note" + + You can also follow this tutorial using your local development environment. To do this, you need to: + + 1. Install [Python](https://www.python.org/downloads/) + 2. Copy the example above into a file called `app.py` + 3. Install ReactPy for your [backend](../reference/fastapi.md), for example `pip install reactpy[fastapi]` + 4. Add `reactpy.run(...)` to the end of your Python file + 5. Run `python app.py` to start a local server and follow the prompts to view the code running in a browser + + If you get stuck, don't let this stop you! Follow along online instead and try a local setup again later. + +## Overview + +Now that you're set up, let's get an overview of React! + +### Inspecting the starter code + +In Jupyter you'll see three main sections: + +<!-- TODO: Add screenshot --> + +![TODO: screenshot of Jupyter]() + +1. The _Files_ section with a list of files like `tic-tac-toe.ipynb` +2. The _interactive code notebook_ where you'll see the source code for each step +3. The _run button_ located on top of the notebook in the command strip + +The `tic-tac-toe.ipynb` file should be selected in the _Files_ section. Click on the first code box, where the contents of that _code editor_ should be: + +```python linenums="0" +{% include "../../examples/tutorial_tic_tac_toe/setup_for_the_tutorial.py" start="# start" %} +``` + +After clicking the _run button_ the notebook should be displaying a square with a X in it like this: + +<!-- TODO: Add screenshot --> + +![TODO: x-filled square]() + +Now let's have a look at the files in the starter code. + +#### `App.js` + +The code in `App.js` creates a _component_. In React, a component is a piece of reusable code that represents a part of a user interface. Components are used to render, manage, and update the UI elements in your application. Let's look at the component line by line to see what's going on: + +```js +export default function Square() { + return <button className="square">X</button>; +} +``` + +The first line defines a function called `Square`. The `export` JavaScript keyword makes this function accessible outside of this file. The `default` keyword tells other files using your code that it's the main function in your file. + +```js +export default function Square() { + return <button className="square">X</button>; +} +``` + +The second line returns a button. The `return` JavaScript keyword means whatever comes after is returned as a value to the caller of the function. `<button>` is a _JSX element_. A JSX element is a combination of JavaScript code and HTML tags that describes what you'd like to display. `className="square"` is a button property or _prop_ that tells CSS how to style the button. `X` is the text displayed inside of the button and `</button>` closes the JSX element to indicate that any following content shouldn't be placed inside the button. + +#### `styles.css` + +Click on the file labeled `styles.css` in the _Files_ section of CodeSandbox. This file defines the styles for your React app. The first two _CSS selectors_ (`*` and `body`) define the style of large parts of your app while the `.square` selector defines the style of any component where the `className` property is set to `square`. In your code, that would match the button from your Square component in the `App.js` file. + +#### `index.js` + +Click on the file labeled `index.js` in the _Files_ section of CodeSandbox. You won't be editing this file during the tutorial but it is the bridge between the component you created in the `App.js` file and the web browser. + +```jsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; + +import App from "./App"; +``` + +Lines 1-5 brings all the necessary pieces together: + +- React +- React's library to talk to web browsers (React DOM) +- the styles for your components +- the component you created in `App.js`. + +The remainder of the file brings all the pieces together and injects the final product into `index.html` in the `public` folder. + +### Building the board + +Let's get back to `App.js`. This is where you'll spend the rest of the tutorial. + +Currently the board is only a single square, but you need nine! If you just try and copy paste your square to make two squares like this: + +```js +export default function Square() { + return <button className="square">X</button><button className="square">X</button>; +} +``` + +You'll get this error: + +<ConsoleBlock level="error"> + +/src/App.js: Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment `<>...</>`? + +</ConsoleBlock> + +React components need to return a single JSX element and not multiple adjacent JSX elements like two buttons. To fix this you can use _fragments_ (`<>` and `</>`) to wrap multiple adjacent JSX elements like this: + +```js +export default function Square() { + return ( + <> + <button className="square">X</button> + <button className="square">X</button> + </> + ); +} +``` + +Now you should see: + +![two x-filled squares]() + +Great! Now you just need to copy-paste a few times to add nine squares and... + +![nine x-filled squares in a line]() + +Oh no! The squares are all in a single line, not in a grid like you need for our board. To fix this you'll need to group your squares into rows with `div`s and add some CSS classes. While you're at it, you'll give each square a number to make sure you know where each square is displayed. + +In the `App.js` file, update the `Square` component to look like this: + +```js +export default function Square() { + return ( + <> + <div className="board-row"> + <button className="square">1</button> + <button className="square">2</button> + <button className="square">3</button> + </div> + <div className="board-row"> + <button className="square">4</button> + <button className="square">5</button> + <button className="square">6</button> + </div> + <div className="board-row"> + <button className="square">7</button> + <button className="square">8</button> + <button className="square">9</button> + </div> + </> + ); +} +``` + +The CSS defined in `styles.css` styles the divs with the `className` of `board-row`. Now that you've grouped your components into rows with the styled `div`s you have your tic-tac-toe board: + +![tic-tac-toe board filled with numbers 1 through 9]() + +But you now have a problem. Your component named `Square`, really isn't a square anymore. Let's fix that by changing the name to `Board`: + +```js +export default function Board() { + //... +} +``` + +At this point your code should look something like this: + +```js +export default function Board() { + return ( + <> + <div className="board-row"> + <button className="square">1</button> + <button className="square">2</button> + <button className="square">3</button> + </div> + <div className="board-row"> + <button className="square">4</button> + <button className="square">5</button> + <button className="square">6</button> + </div> + <div className="board-row"> + <button className="square">7</button> + <button className="square">8</button> + <button className="square">9</button> + </div> + </> + ); +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +<Note> + +Psssst... That's a lot to type! It's okay to copy and paste code from this page. However, if you're up for a little challenge, we recommend only copying code that you've manually typed at least once yourself. + +</Note> + +### Passing data through props + +Next, you'll want to change the value of a square from empty to "X" when the user clicks on the square. With how you've built the board so far you would need to copy-paste the code that updates the square nine times (once for each square you have)! Instead of copy-pasting, React's component architecture allows you to create a reusable component to avoid messy, duplicated code. + +First, you are going to copy the line defining your first square (`<button className="square">1</button>`) from your `Board` component into a new `Square` component: + +```js +function Square() { + return <button className="square">1</button>; +} + +export default function Board() { + // ... +} +``` + +Then you'll update the Board component to render that `Square` component using JSX syntax: + +```js +// ... +export default function Board() { + return ( + <> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + </> + ); +} +``` + +Note how unlike the browser `div`s, your own components `Board` and `Square` must start with a capital letter. + +Let's take a look: + +![one-filled board]() + +Oh no! You lost the numbered squares you had before. Now each square says "1". To fix this, you will use _props_ to pass the value each square should have from the parent component (`Board`) to its child (`Square`). + +Update the `Square` component to read the `value` prop that you'll pass from the `Board`: + +```js +function Square({ value }) { + return <button className="square">1</button>; +} +``` + +`function Square({ value })` indicates the Square component can be passed a prop called `value`. + +Now you want to display that `value` instead of `1` inside every square. Try doing it like this: + +```js +function Square({ value }) { + return <button className="square">value</button>; +} +``` + +Oops, this is not what you wanted: + +![value-filled board]() + +You wanted to render the JavaScript variable called `value` from your component, not the word "value". To "escape into JavaScript" from JSX, you need curly braces. Add curly braces around `value` in JSX like so: + +```js +function Square({ value }) { + return <button className="square">{value}</button>; +} +``` + +For now, you should see an empty board: + +![empty board]() + +This is because the `Board` component hasn't passed the `value` prop to each `Square` component it renders yet. To fix it you'll add the `value` prop to each `Square` component rendered by the `Board` component: + +```js +export default function Board() { + return ( + <> + <div className="board-row"> + <Square value="1" /> + <Square value="2" /> + <Square value="3" /> + </div> + <div className="board-row"> + <Square value="4" /> + <Square value="5" /> + <Square value="6" /> + </div> + <div className="board-row"> + <Square value="7" /> + <Square value="8" /> + <Square value="9" /> + </div> + </> + ); +} +``` + +Now you should see a grid of numbers again: + +![tic-tac-toe board filled with numbers 1 through 9]() + +Your updated code should look like this: + +```js +function Square({ value }) { + return <button className="square">{value}</button>; +} + +export default function Board() { + return ( + <> + <div className="board-row"> + <Square value="1" /> + <Square value="2" /> + <Square value="3" /> + </div> + <div className="board-row"> + <Square value="4" /> + <Square value="5" /> + <Square value="6" /> + </div> + <div className="board-row"> + <Square value="7" /> + <Square value="8" /> + <Square value="9" /> + </div> + </> + ); +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +### Making an interactive component + +Let's fill the `Square` component with an `X` when you click it. Declare a function called `handleClick` inside of the `Square`. Then, add `on_click` to the props of the button JSX element returned from the `Square`: + +```js +function Square({ value }) { + function handleClick() { + console.log("clicked!"); + } + + return ( + <button className="square" on_click={handleClick}> + {value} + </button> + ); +} +``` + +If you click on a square now, you should see a log saying `"clicked!"` in the _Console_ tab at the bottom of the _Browser_ section in CodeSandbox. Clicking the square more than once will log `"clicked!"` again. Repeated console logs with the same message will not create more lines in the console. Instead, you will see an incrementing counter next to your first `"clicked!"` log. + +<Note> + +If you are following this tutorial using your local development environment, you need to open your browser's Console. For example, if you use the Chrome browser, you can view the Console with the keyboard shortcut **Shift + Ctrl + J** (on Windows/Linux) or **Option + ⌘ + J** (on macOS). + +</Note> + +As a next step, you want the Square component to "remember" that it got clicked, and fill it with an "X" mark. To "remember" things, components use _state_. + +React provides a special function called `useState` that you can call from your component to let it "remember" things. Let's store the current value of the `Square` in state, and change it when the `Square` is clicked. + +Import `useState` at the top of the file. Remove the `value` prop from the `Square` component. Instead, add a new line at the start of the `Square` that calls `useState`. Have it return a state variable called `value`: + +```js +import { useState } from 'react'; + +function Square() { + const [value, setValue] = useState(null); + + function handleClick() { + //... +``` + +`value` stores the value and `setValue` is a function that can be used to change the value. The `null` passed to `useState` is used as the initial value for this state variable, so `value` here starts off equal to `null`. + +Since the `Square` component no longer accepts props anymore, you'll remove the `value` prop from all nine of the Square components created by the Board component: + +```js +// ... +export default function Board() { + return ( + <> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + </> + ); +} +``` + +Now you'll change `Square` to display an "X" when clicked. Replace the `console.log("clicked!");` event handler with `setValue('X');`. Now your `Square` component looks like this: + +```js +function Square() { + const [value, setValue] = useState(null); + + function handleClick() { + setValue("X"); + } + + return ( + <button className="square" on_click={handleClick}> + {value} + </button> + ); +} +``` + +By calling this `set` function from an `on_click` handler, you're telling React to re-render that `Square` whenever its `<button>` is clicked. After the update, the `Square`'s `value` will be `'X'`, so you'll see the "X" on the game board. Click on any Square, and "X" should show up: + +![adding xes to board]() + +Each Square has its own state: the `value` stored in each Square is completely independent of the others. When you call a `set` function in a component, React automatically updates the child components inside too. + +After you've made the above changes, your code will look like this: + +```js +import { useState } from "react"; + +function Square() { + const [value, setValue] = useState(null); + + function handleClick() { + setValue("X"); + } + + return ( + <button className="square" on_click={handleClick}> + {value} + </button> + ); +} + +export default function Board() { + return ( + <> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + <div className="board-row"> + <Square /> + <Square /> + <Square /> + </div> + </> + ); +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +### React Developer Tools + +React DevTools let you check the props and the state of your React components. You can find the React DevTools tab at the bottom of the _browser_ section in CodeSandbox: + +![React DevTools in CodeSandbox]() + +To inspect a particular component on the screen, use the button in the top left corner of React DevTools: + +![Selecting components on the page with React DevTools]() + +<Note> + +For local development, React DevTools is available as a [Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/react-devtools/), and [Edge](https://microsoftedge.microsoft.com/addons/detail/react-developer-tools/gpphkfbcpidddadnkolkpfckpihlkkil) browser extension. Install it, and the _Components_ tab will appear in your browser Developer Tools for sites using React. + +</Note> + +## Completing the game + +By this point, you have all the basic building blocks for your tic-tac-toe game. To have a complete game, you now need to alternate placing "X"s and "O"s on the board, and you need a way to determine a winner. + +### Lifting state up + +Currently, each `Square` component maintains a part of the game's state. To check for a winner in a tic-tac-toe game, the `Board` would need to somehow know the state of each of the 9 `Square` components. + +How would you approach that? At first, you might guess that the `Board` needs to "ask" each `Square` for that `Square`'s state. Although this approach is technically possible in React, we discourage it because the code becomes difficult to understand, susceptible to bugs, and hard to refactor. Instead, the best approach is to store the game's state in the parent `Board` component instead of in each `Square`. The `Board` component can tell each `Square` what to display by passing a prop, like you did when you passed a number to each Square. + +**To collect data from multiple children, or to have two child components communicate with each other, declare the shared state in their parent component instead. The parent component can pass that state back down to the children via props. This keeps the child components in sync with each other and with their parent.** + +Lifting state into a parent component is common when React components are refactored. + +Let's take this opportunity to try it out. Edit the `Board` component so that it declares a state variable named `squares` that defaults to an array of 9 nulls corresponding to the 9 squares: + +```js +// ... +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + return ( + // ... + ); +} +``` + +`Array(9).fill(null)` creates an array with nine elements and sets each of them to `null`. The `useState()` call around it declares a `squares` state variable that's initially set to that array. Each entry in the array corresponds to the value of a square. When you fill the board in later, the `squares` array will look like this: + +```jsx +["O", null, "X", "X", "X", "O", "O", null, null]; +``` + +Now your `Board` component needs to pass the `value` prop down to each `Square` that it renders: + +```js +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + return ( + <> + <div className="board-row"> + <Square value={squares[0]} /> + <Square value={squares[1]} /> + <Square value={squares[2]} /> + </div> + <div className="board-row"> + <Square value={squares[3]} /> + <Square value={squares[4]} /> + <Square value={squares[5]} /> + </div> + <div className="board-row"> + <Square value={squares[6]} /> + <Square value={squares[7]} /> + <Square value={squares[8]} /> + </div> + </> + ); +} +``` + +Next, you'll edit the `Square` component to receive the `value` prop from the Board component. This will require removing the Square component's own stateful tracking of `value` and the button's `on_click` prop: + +```js +function Square({ value }) { + return <button className="square">{value}</button>; +} +``` + +At this point you should see an empty tic-tac-toe board: + +![empty board]() + +And your code should look like this: + +```js +import { useState } from "react"; + +function Square({ value }) { + return <button className="square">{value}</button>; +} + +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + return ( + <> + <div className="board-row"> + <Square value={squares[0]} /> + <Square value={squares[1]} /> + <Square value={squares[2]} /> + </div> + <div className="board-row"> + <Square value={squares[3]} /> + <Square value={squares[4]} /> + <Square value={squares[5]} /> + </div> + <div className="board-row"> + <Square value={squares[6]} /> + <Square value={squares[7]} /> + <Square value={squares[8]} /> + </div> + </> + ); +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +Each Square will now receive a `value` prop that will either be `'X'`, `'O'`, or `null` for empty squares. + +Next, you need to change what happens when a `Square` is clicked. The `Board` component now maintains which squares are filled. You'll need to create a way for the `Square` to update the `Board`'s state. Since state is private to a component that defines it, you cannot update the `Board`'s state directly from `Square`. + +Instead, you'll pass down a function from the `Board` component to the `Square` component, and you'll have `Square` call that function when a square is clicked. You'll start with the function that the `Square` component will call when it is clicked. You'll call that function `onSquareClick`: + +```js +function Square({ value }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} +``` + +Next, you'll add the `onSquareClick` function to the `Square` component's props: + +```js +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} +``` + +Now you'll connect the `onSquareClick` prop to a function in the `Board` component that you'll name `handleClick`. To connect `onSquareClick` to `handleClick` you'll pass a function to the `onSquareClick` prop of the first `Square` component: + +```js +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + + return ( + <> + <div className="board-row"> + <Square value={squares[0]} onSquareClick={handleClick} /> + //... + ); +} +``` + +Lastly, you will define the `handleClick` function inside the Board component to update the `squares` array holding your board's state: + +```js +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + + function handleClick() { + const nextSquares = squares.slice(); + nextSquares[0] = "X"; + setSquares(nextSquares); + } + + return ( + // ... + ) +} +``` + +The `handleClick` function creates a copy of the `squares` array (`nextSquares`) with the JavaScript `slice()` Array method. Then, `handleClick` updates the `nextSquares` array to add `X` to the first (`[0]` index) square. + +Calling the `setSquares` function lets React know the state of the component has changed. This will trigger a re-render of the components that use the `squares` state (`Board`) as well as its child components (the `Square` components that make up the board). + +<Note> + +JavaScript supports [closures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures) which means an inner function (e.g. `handleClick`) has access to variables and functions defined in a outer function (e.g. `Board`). The `handleClick` function can read the `squares` state and call the `setSquares` method because they are both defined inside of the `Board` function. + +</Note> + +Now you can add X's to the board... but only to the upper left square. Your `handleClick` function is hardcoded to update the index for the upper left square (`0`). Let's update `handleClick` to be able to update any square. Add an argument `i` to the `handleClick` function that takes the index of the square to update: + +```js +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + + function handleClick(i) { + const nextSquares = squares.slice(); + nextSquares[i] = "X"; + setSquares(nextSquares); + } + + return ( + // ... + ) +} +``` + +Next, you will need to pass that `i` to `handleClick`. You could try to set the `onSquareClick` prop of square to be `handleClick(0)` directly in the JSX like this, but it won't work: + +```jsx +<Square value={squares[0]} onSquareClick={handleClick(0)} /> +``` + +Here is why this doesn't work. The `handleClick(0)` call will be a part of rendering the board component. Because `handleClick(0)` alters the state of the board component by calling `setSquares`, your entire board component will be re-rendered again. But this runs `handleClick(0)` again, leading to an infinite loop: + +<ConsoleBlock level="error"> + +Too many re-renders. React limits the number of renders to prevent an infinite loop. + +</ConsoleBlock> + +Why didn't this problem happen earlier? + +When you were passing `onSquareClick={handleClick}`, you were passing the `handleClick` function down as a prop. You were not calling it! But now you are _calling_ that function right away--notice the parentheses in `handleClick(0)`--and that's why it runs too early. You don't _want_ to call `handleClick` until the user clicks! + +You could fix by creating a function like `handleFirstSquareClick` that calls `handleClick(0)`, a function like `handleSecondSquareClick` that calls `handleClick(1)`, and so on. You would pass (rather than call) these functions down as props like `onSquareClick={handleFirstSquareClick}`. This would solve the infinite loop. + +However, defining nine different functions and giving each of them a name is too verbose. Instead, let's do this: + +```js +export default function Board() { + // ... + return ( + <> + <div className="board-row"> + <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> + // ... + ); +} +``` + +Notice the new `() =>` syntax. Here, `() => handleClick(0)` is an _arrow function,_ which is a shorter way to define functions. When the square is clicked, the code after the `=>` "arrow" will run, calling `handleClick(0)`. + +Now you need to update the other eight squares to call `handleClick` from the arrow functions you pass. Make sure that the argument for each call of the `handleClick` corresponds to the index of the correct square: + +```js +export default function Board() { + // ... + return ( + <> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} +``` + +Now you can again add X's to any square on the board by clicking on them: + +![filling the board with X]() + +But this time all the state management is handled by the `Board` component! + +This is what your code should look like: + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +export default function Board() { + const [squares, setSquares] = useState(Array(9).fill(null)); + + function handleClick(i) { + const nextSquares = squares.slice(); + nextSquares[i] = "X"; + setSquares(nextSquares); + } + + return ( + <> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +Now that your state handling is in the `Board` component, the parent `Board` component passes props to the child `Square` components so that they can be displayed correctly. When clicking on a `Square`, the child `Square` component now asks the parent `Board` component to update the state of the board. When the `Board`'s state changes, both the `Board` component and every child `Square` re-renders automatically. Keeping the state of all squares in the `Board` component will allow it to determine the winner in the future. + +Let's recap what happens when a user clicks the top left square on your board to add an `X` to it: + +1. Clicking on the upper left square runs the function that the `button` received as its `on_click` prop from the `Square`. The `Square` component received that function as its `onSquareClick` prop from the `Board`. The `Board` component defined that function directly in the JSX. It calls `handleClick` with an argument of `0`. +1. `handleClick` uses the argument (`0`) to update the first element of the `squares` array from `null` to `X`. +1. The `squares` state of the `Board` component was updated, so the `Board` and all of its children re-render. This causes the `value` prop of the `Square` component with index `0` to change from `null` to `X`. + +In the end the user sees that the upper left square has changed from empty to having a `X` after clicking it. + +<Note> + +The DOM `<button>` element's `on_click` attribute has a special meaning to React because it is a built-in component. For custom components like Square, the naming is up to you. You could give any name to the `Square`'s `onSquareClick` prop or `Board`'s `handleClick` function, and the code would work the same. In React, it's conventional to use `onSomething` names for props which represent events and `handleSomething` for the function definitions which handle those events. + +</Note> + +### Why immutability is important + +Note how in `handleClick`, you call `.slice()` to create a copy of the `squares` array instead of modifying the existing array. To explain why, we need to discuss immutability and why immutability is important to learn. + +There are generally two approaches to changing data. The first approach is to _mutate_ the data by directly changing the data's values. The second approach is to replace the data with a new copy which has the desired changes. Here is what it would look like if you mutated the `squares` array: + +```jsx +const squares = [null, null, null, null, null, null, null, null, null]; +squares[0] = "X"; +// Now `squares` is ["X", null, null, null, null, null, null, null, null]; +``` + +And here is what it would look like if you changed data without mutating the `squares` array: + +```jsx +const squares = [null, null, null, null, null, null, null, null, null]; +const nextSquares = ["X", null, null, null, null, null, null, null, null]; +// Now `squares` is unchanged, but `nextSquares` first element is 'X' rather than `null` +``` + +The result is the same but by not mutating (changing the underlying data) directly, you gain several benefits. + +Immutability makes complex features much easier to implement. Later in this tutorial, you will implement a "time travel" feature that lets you review the game's history and "jump back" to past moves. This functionality isn't specific to games--an ability to undo and redo certain actions is a common requirement for apps. Avoiding direct data mutation lets you keep previous versions of the data intact, and reuse them later. + +There is also another benefit of immutability. By default, all child components re-render automatically when the state of a parent component changes. This includes even the child components that weren't affected by the change. Although re-rendering is not by itself noticeable to the user (you shouldn't actively try to avoid it!), you might want to skip re-rendering a part of the tree that clearly wasn't affected by it for performance reasons. Immutability makes it very cheap for components to compare whether their data has changed or not. You can learn more about how React chooses when to re-render a component in [the `memo` API reference](/reference/react/memo). + +### Taking turns + +It's now time to fix a major defect in this tic-tac-toe game: the "O"s cannot be marked on the board. + +You'll set the first move to be "X" by default. Let's keep track of this by adding another piece of state to the Board component: + +```js +function Board() { + const [xIsNext, setXIsNext] = useState(true); + const [squares, setSquares] = useState(Array(9).fill(null)); + + // ... +} +``` + +Each time a player moves, `xIsNext` (a boolean) will be flipped to determine which player goes next and the game's state will be saved. You'll update the `Board`'s `handleClick` function to flip the value of `xIsNext`: + +```js +export default function Board() { + const [xIsNext, setXIsNext] = useState(true); + const [squares, setSquares] = useState(Array(9).fill(null)); + + function handleClick(i) { + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + setSquares(nextSquares); + setXIsNext(!xIsNext); + } + + return ( + //... + ); +} +``` + +Now, as you click on different squares, they will alternate between `X` and `O`, as they should! + +But wait, there's a problem. Try clicking on the same square multiple times: + +![O overwriting an X]() + +The `X` is overwritten by an `O`! While this would add a very interesting twist to the game, we're going to stick to the original rules for now. + +When you mark a square with a `X` or an `O` you aren't first checking to see if the square already has a `X` or `O` value. You can fix this by _returning early_. You'll check to see if the square already has a `X` or an `O`. If the square is already filled, you will `return` in the `handleClick` function early--before it tries to update the board state. + +```js +function handleClick(i) { + if (squares[i]) { + return; + } + const nextSquares = squares.slice(); + //... +} +``` + +Now you can only add `X`'s or `O`'s to empty squares! Here is what your code should look like at this point: + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +export default function Board() { + const [xIsNext, setXIsNext] = useState(true); + const [squares, setSquares] = useState(Array(9).fill(null)); + + function handleClick(i) { + if (squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + setSquares(nextSquares); + setXIsNext(!xIsNext); + } + + return ( + <> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +### Declaring a winner + +Now that the players can take turns, you'll want to show when the game is won and there are no more turns to make. To do this you'll add a helper function called `calculateWinner` that takes an array of 9 squares, checks for a winner and returns `'X'`, `'O'`, or `null` as appropriate. Don't worry too much about the `calculateWinner` function; it's not specific to React: + +```js +export default function Board() { + //... +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +<Note> + +It does not matter whether you define `calculateWinner` before or after the `Board`. Let's put it at the end so that you don't have to scroll past it every time you edit your components. + +</Note> + +You will call `calculateWinner(squares)` in the `Board` component's `handleClick` function to check if a player has won. You can perform this check at the same time you check if a user has clicked a square that already has a `X` or and `O`. We'd like to return early in both cases: + +```js +function handleClick(i) { + if (squares[i] || calculateWinner(squares)) { + return; + } + const nextSquares = squares.slice(); + //... +} +``` + +To let the players know when the game is over, you can display text such as "Winner: X" or "Winner: O". To do that you'll add a `status` section to the `Board` component. The status will display the winner if the game is over and if the game is ongoing you'll display which player's turn is next: + +```js +export default function Board() { + // ... + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + // ... + ) +} +``` + +Congratulations! You now have a working tic-tac-toe game. And you've just learned the basics of React too. So _you_ are the real winner here. Here is what the code should look like: + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +export default function Board() { + const [xIsNext, setXIsNext] = useState(true); + const [squares, setSquares] = useState(Array(9).fill(null)); + + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + setSquares(nextSquares); + setXIsNext(!xIsNext); + } + + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +## Adding time travel + +As a final exercise, let's make it possible to "go back in time" to the previous moves in the game. + +### Storing a history of moves + +If you mutated the `squares` array, implementing time travel would be very difficult. + +However, you used `slice()` to create a new copy of the `squares` array after every move, and treated it as immutable. This will allow you to store every past version of the `squares` array, and navigate between the turns that have already happened. + +You'll store the past `squares` arrays in another array called `history`, which you'll store as a new state variable. The `history` array represents all board states, from the first to the last move, and has a shape like this: + +```jsx +[ + // Before first move + [null, null, null, null, null, null, null, null, null], + // After first move + [null, null, null, null, "X", null, null, null, null], + // After second move + [null, null, null, null, "X", null, null, null, "O"], + // ... +]; +``` + +### Lifting state up, again + +You will now write a new top-level component called `Game` to display a list of past moves. That's where you will place the `history` state that contains the entire game history. + +Placing the `history` state into the `Game` component will let you remove the `squares` state from its child `Board` component. Just like you "lifted state up" from the `Square` component into the `Board` component, you will now lift it up from the `Board` into the top-level `Game` component. This gives the `Game` component full control over the `Board`'s data and lets it instruct the `Board` to render previous turns from the `history`. + +First, add a `Game` component with `export default`. Have it render the `Board` component and some markup: + +```js +function Board() { + // ... +} + +export default function Game() { + return ( + <div className="game"> + <div className="game-board"> + <Board /> + </div> + <div className="game-info"> + <ol>{/*TODO*/}</ol> + </div> + </div> + ); +} +``` + +Note that you are removing the `export default` keywords before the `function Board() {` declaration and adding them before the `function Game() {` declaration. This tells your `index.js` file to use the `Game` component as the top-level component instead of your `Board` component. The additional `div`s returned by the `Game` component are making room for the game information you'll add to the board later. + +Add some state to the `Game` component to track which player is next and the history of moves: + +```js +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + // ... +``` + +Notice how `[Array(9).fill(null)]` is an array with a single item, which itself is an array of 9 `null`s. + +To render the squares for the current move, you'll want to read the last squares array from the `history`. You don't need `useState` for this--you already have enough information to calculate it during rendering: + +```js +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const currentSquares = history[history.length - 1]; + // ... +``` + +Next, create a `handlePlay` function inside the `Game` component that will be called by the `Board` component to update the game. Pass `xIsNext`, `currentSquares` and `handlePlay` as props to the `Board` component: + +```js +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const currentSquares = history[history.length - 1]; + + function handlePlay(nextSquares) { + // TODO + } + + return ( + <div className="game"> + <div className="game-board"> + <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> + //... + ) +} +``` + +Let's make the `Board` component fully controlled by the props it receives. Change the `Board` component to take three props: `xIsNext`, `squares`, and a new `onPlay` function that `Board` can call with the updated squares array when a player makes a move. Next, remove the first two lines of the `Board` function that call `useState`: + +```js +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + //... + } + // ... +} +``` + +Now replace the `setSquares` and `setXIsNext` calls in `handleClick` in the `Board` component with a single call to your new `onPlay` function so the `Game` component can update the `Board` when the user clicks a square: + +```js +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + onPlay(nextSquares); + } + //... +} +``` + +The `Board` component is fully controlled by the props passed to it by the `Game` component. You need to implement the `handlePlay` function in the `Game` component to get the game working again. + +What should `handlePlay` do when called? Remember that Board used to call `setSquares` with an updated array; now it passes the updated `squares` array to `onPlay`. + +The `handlePlay` function needs to update `Game`'s state to trigger a re-render, but you don't have a `setSquares` function that you can call any more--you're now using the `history` state variable to store this information. You'll want to update `history` by appending the updated `squares` array as a new history entry. You also want to toggle `xIsNext`, just as Board used to do: + +```js +export default function Game() { + //... + function handlePlay(nextSquares) { + setHistory([...history, nextSquares]); + setXIsNext(!xIsNext); + } + //... +} +``` + +Here, `[...history, nextSquares]` creates a new array that contains all the items in `history`, followed by `nextSquares`. (You can read the `...history` [_spread syntax_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) as "enumerate all the items in `history`".) + +For example, if `history` is `[[null,null,null], ["X",null,null]]` and `nextSquares` is `["X",null,"O"]`, then the new `[...history, nextSquares]` array will be `[[null,null,null], ["X",null,null], ["X",null,"O"]]`. + +At this point, you've moved the state to live in the `Game` component, and the UI should be fully working, just as it was before the refactor. Here is what the code should look like at this point: + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + onPlay(nextSquares); + } + + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} + +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const currentSquares = history[history.length - 1]; + + function handlePlay(nextSquares) { + setHistory([...history, nextSquares]); + setXIsNext(!xIsNext); + } + + return ( + <div className="game"> + <div className="game-board"> + <Board + xIsNext={xIsNext} + squares={currentSquares} + onPlay={handlePlay} + /> + </div> + <div className="game-info"> + <ol>{/*TODO*/}</ol> + </div> + </div> + ); +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +### Showing the past moves + +Since you are recording the tic-tac-toe game's history, you can now display a list of past moves to the player. + +React elements like `<button>` are regular JavaScript objects; you can pass them around in your application. To render multiple items in React, you can use an array of React elements. + +You already have an array of `history` moves in state, so now you need to transform it to an array of React elements. In JavaScript, to transform one array into another, you can use the [array `map` method:](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) + +```jsx +[1, 2, 3].map((x) => x * 2); // [2, 4, 6] +``` + +You'll use `map` to transform your `history` of moves into React elements representing buttons on the screen, and display a list of buttons to "jump" to past moves. Let's `map` over the `history` in the Game component: + +```js +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const currentSquares = history[history.length - 1]; + + function handlePlay(nextSquares) { + setHistory([...history, nextSquares]); + setXIsNext(!xIsNext); + } + + function jumpTo(nextMove) { + // TODO + } + + const moves = history.map((squares, move) => { + let description; + if (move > 0) { + description = "Go to move #" + move; + } else { + description = "Go to game start"; + } + return ( + <li> + <button on_click={() => jumpTo(move)}>{description}</button> + </li> + ); + }); + + return ( + <div className="game"> + <div className="game-board"> + <Board + xIsNext={xIsNext} + squares={currentSquares} + onPlay={handlePlay} + /> + </div> + <div className="game-info"> + <ol>{moves}</ol> + </div> + </div> + ); +} +``` + +You can see what your code should look like below. Note that you should see an error in the developer tools console that says: `` Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `Game`. `` You'll fix this error in the next section. + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + onPlay(nextSquares); + } + + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} + +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const currentSquares = history[history.length - 1]; + + function handlePlay(nextSquares) { + setHistory([...history, nextSquares]); + setXIsNext(!xIsNext); + } + + function jumpTo(nextMove) { + // TODO + } + + const moves = history.map((squares, move) => { + let description; + if (move > 0) { + description = "Go to move #" + move; + } else { + description = "Go to game start"; + } + return ( + <li> + <button on_click={() => jumpTo(move)}>{description}</button> + </li> + ); + }); + + return ( + <div className="game"> + <div className="game-board"> + <Board + xIsNext={xIsNext} + squares={currentSquares} + onPlay={handlePlay} + /> + </div> + <div className="game-info"> + <ol>{moves}</ol> + </div> + </div> + ); +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} + +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +As you iterate through `history` array inside the function you passed to `map`, the `squares` argument goes through each element of `history`, and the `move` argument goes through each array index: `0`, `1`, `2`, …. (In most cases, you'd need the actual array elements, but to render a list of moves you will only need indexes.) + +For each move in the tic-tac-toe game's history, you create a list item `<li>` which contains a button `<button>`. The button has an `on_click` handler which calls a function called `jumpTo` (that you haven't implemented yet). + +For now, you should see a list of the moves that occurred in the game and an error in the developer tools console. Let's discuss what the "key" error means. + +### Picking a key + +When you render a list, React stores some information about each rendered list item. When you update a list, React needs to determine what has changed. You could have added, removed, re-arranged, or updated the list's items. + +Imagine transitioning from + +```html +<li>Alexa: 7 tasks left</li> +<li>Ben: 5 tasks left</li> +``` + +to + +```html +<li>Ben: 9 tasks left</li> +<li>Claudia: 8 tasks left</li> +<li>Alexa: 5 tasks left</li> +``` + +In addition to the updated counts, a human reading this would probably say that you swapped Alexa and Ben's ordering and inserted Claudia between Alexa and Ben. However, React is a computer program and can't know what you intended, so you need to specify a _key_ property for each list item to differentiate each list item from its siblings. If your data was from a database, Alexa, Ben, and Claudia's database IDs could be used as keys. + +```js +<li key={user.id}> + {user.name}: {user.taskCount} tasks left +</li> +``` + +When a list is re-rendered, React takes each list item's key and searches the previous list's items for a matching key. If the current list has a key that didn't exist before, React creates a component. If the current list is missing a key that existed in the previous list, React destroys the previous component. If two keys match, the corresponding component is moved. + +Keys tell React about the identity of each component, which allows React to maintain state between re-renders. If a component's key changes, the component will be destroyed and re-created with a new state. + +`key` is a special and reserved property in React. When an element is created, React extracts the `key` property and stores the key directly on the returned element. Even though `key` may look like it is passed as props, React automatically uses `key` to decide which components to update. There's no way for a component to ask what `key` its parent specified. + +**It's strongly recommended that you assign proper keys whenever you build dynamic lists.** If you don't have an appropriate key, you may want to consider restructuring your data so that you do. + +If no key is specified, React will report an error and use the array index as a key by default. Using the array index as a key is problematic when trying to re-order a list's items or inserting/removing list items. Explicitly passing `key={i}` silences the error but has the same problems as array indices and is not recommended in most cases. + +Keys do not need to be globally unique; they only need to be unique between components and their siblings. + +### Implementing time travel + +In the tic-tac-toe game's history, each past move has a unique ID associated with it: it's the sequential number of the move. Moves will never be re-ordered, deleted, or inserted in the middle, so it's safe to use the move index as a key. + +In the `Game` function, you can add the key as `<li key={move}>`, and if you reload the rendered game, React's "key" error should disappear: + +```js +const moves = history.map((squares, move) => { + //... + return ( + <li key={move}> + <button on_click={() => jumpTo(move)}>{description}</button> + </li> + ); +}); +``` + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + onPlay(nextSquares); + } + + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} + +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const currentSquares = history[history.length - 1]; + + function handlePlay(nextSquares) { + setHistory([...history, nextSquares]); + setXIsNext(!xIsNext); + } + + function jumpTo(nextMove) { + // TODO + } + + const moves = history.map((squares, move) => { + let description; + if (move > 0) { + description = "Go to move #" + move; + } else { + description = "Go to game start"; + } + return ( + <li key={move}> + <button on_click={() => jumpTo(move)}>{description}</button> + </li> + ); + }); + + return ( + <div className="game"> + <div className="game-board"> + <Board + xIsNext={xIsNext} + squares={currentSquares} + onPlay={handlePlay} + /> + </div> + <div className="game-info"> + <ol>{moves}</ol> + </div> + </div> + ); +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} + +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +Before you can implement `jumpTo`, you need the `Game` component to keep track of which step the user is currently viewing. To do this, define a new state variable called `currentMove`, defaulting to `0`: + +```js +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const [currentMove, setCurrentMove] = useState(0); + const currentSquares = history[history.length - 1]; + //... +} +``` + +Next, update the `jumpTo` function inside `Game` to update that `currentMove`. You'll also set `xIsNext` to `true` if the number that you're changing `currentMove` to is even. + +```js +export default function Game() { + // ... + function jumpTo(nextMove) { + setCurrentMove(nextMove); + setXIsNext(nextMove % 2 === 0); + } + //... +} +``` + +You will now make two changes to the `Game`'s `handlePlay` function which is called when you click on a square. + +- If you "go back in time" and then make a new move from that point, you only want to keep the history up to that point. Instead of adding `nextSquares` after all items (`...` spread syntax) in `history`, you'll add it after all items in `history.slice(0, currentMove + 1)` so that you're only keeping that portion of the old history. +- Each time a move is made, you need to update `currentMove` to point to the latest history entry. + +```js +function handlePlay(nextSquares) { + const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; + setHistory(nextHistory); + setCurrentMove(nextHistory.length - 1); + setXIsNext(!xIsNext); +} +``` + +Finally, you will modify the `Game` component to render the currently selected move, instead of always rendering the final move: + +```js +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const [currentMove, setCurrentMove] = useState(0); + const currentSquares = history[currentMove]; + + // ... +} +``` + +If you click on any step in the game's history, the tic-tac-toe board should immediately update to show what the board looked like after that step occurred. + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + onPlay(nextSquares); + } + + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} + +export default function Game() { + const [xIsNext, setXIsNext] = useState(true); + const [history, setHistory] = useState([Array(9).fill(null)]); + const [currentMove, setCurrentMove] = useState(0); + const currentSquares = history[currentMove]; + + function handlePlay(nextSquares) { + const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; + setHistory(nextHistory); + setCurrentMove(nextHistory.length - 1); + setXIsNext(!xIsNext); + } + + function jumpTo(nextMove) { + setCurrentMove(nextMove); + setXIsNext(nextMove % 2 === 0); + } + + const moves = history.map((squares, move) => { + let description; + if (move > 0) { + description = "Go to move #" + move; + } else { + description = "Go to game start"; + } + return ( + <li key={move}> + <button on_click={() => jumpTo(move)}>{description}</button> + </li> + ); + }); + + return ( + <div className="game"> + <div className="game-board"> + <Board + xIsNext={xIsNext} + squares={currentSquares} + onPlay={handlePlay} + /> + </div> + <div className="game-info"> + <ol>{moves}</ol> + </div> + </div> + ); +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +### Final cleanup + +If you look at the code very closely, you may notice that `xIsNext === true` when `currentMove` is even and `xIsNext === false` when `currentMove` is odd. In other words, if you know the value of `currentMove`, then you can always figure out what `xIsNext` should be. + +There's no reason for you to store both of these in state. In fact, always try to avoid redundant state. Simplifying what you store in state reduces bugs and makes your code easier to understand. Change `Game` so that it doesn't store `xIsNext` as a separate state variable and instead figures it out based on the `currentMove`: + +```js +export default function Game() { + const [history, setHistory] = useState([Array(9).fill(null)]); + const [currentMove, setCurrentMove] = useState(0); + const xIsNext = currentMove % 2 === 0; + const currentSquares = history[currentMove]; + + function handlePlay(nextSquares) { + const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; + setHistory(nextHistory); + setCurrentMove(nextHistory.length - 1); + } + + function jumpTo(nextMove) { + setCurrentMove(nextMove); + } + // ... +} +``` + +You no longer need the `xIsNext` state declaration or the calls to `setXIsNext`. Now, there's no chance for `xIsNext` to get out of sync with `currentMove`, even if you make a mistake while coding the components. + +### Wrapping up + +Congratulations! You've created a tic-tac-toe game that: + +- Lets you play tic-tac-toe, +- Indicates when a player has won the game, +- Stores a game's history as a game progresses, +- Allows players to review a game's history and see previous versions of a game's board. + +Nice work! We hope you now feel like you have a decent grasp of how React works. + +Check out the final result here: + +```js +import { useState } from "react"; + +function Square({ value, onSquareClick }) { + return ( + <button className="square" on_click={onSquareClick}> + {value} + </button> + ); +} + +function Board({ xIsNext, squares, onPlay }) { + function handleClick(i) { + if (calculateWinner(squares) || squares[i]) { + return; + } + const nextSquares = squares.slice(); + if (xIsNext) { + nextSquares[i] = "X"; + } else { + nextSquares[i] = "O"; + } + onPlay(nextSquares); + } + + const winner = calculateWinner(squares); + let status; + if (winner) { + status = "Winner: " + winner; + } else { + status = "Next player: " + (xIsNext ? "X" : "O"); + } + + return ( + <> + <div className="status">{status}</div> + <div className="board-row"> + <Square + value={squares[0]} + onSquareClick={() => handleClick(0)} + /> + <Square + value={squares[1]} + onSquareClick={() => handleClick(1)} + /> + <Square + value={squares[2]} + onSquareClick={() => handleClick(2)} + /> + </div> + <div className="board-row"> + <Square + value={squares[3]} + onSquareClick={() => handleClick(3)} + /> + <Square + value={squares[4]} + onSquareClick={() => handleClick(4)} + /> + <Square + value={squares[5]} + onSquareClick={() => handleClick(5)} + /> + </div> + <div className="board-row"> + <Square + value={squares[6]} + onSquareClick={() => handleClick(6)} + /> + <Square + value={squares[7]} + onSquareClick={() => handleClick(7)} + /> + <Square + value={squares[8]} + onSquareClick={() => handleClick(8)} + /> + </div> + </> + ); +} + +export default function Game() { + const [history, setHistory] = useState([Array(9).fill(null)]); + const [currentMove, setCurrentMove] = useState(0); + const xIsNext = currentMove % 2 === 0; + const currentSquares = history[currentMove]; + + function handlePlay(nextSquares) { + const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; + setHistory(nextHistory); + setCurrentMove(nextHistory.length - 1); + } + + function jumpTo(nextMove) { + setCurrentMove(nextMove); + } + + const moves = history.map((squares, move) => { + let description; + if (move > 0) { + description = "Go to move #" + move; + } else { + description = "Go to game start"; + } + return ( + <li key={move}> + <button on_click={() => jumpTo(move)}>{description}</button> + </li> + ); + }); + + return ( + <div className="game"> + <div className="game-board"> + <Board + xIsNext={xIsNext} + squares={currentSquares} + onPlay={handlePlay} + /> + </div> + <div className="game-info"> + <ol>{moves}</ol> + </div> + </div> + ); +} + +function calculateWinner(squares) { + const lines = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (let i = 0; i < lines.length; i++) { + const [a, b, c] = lines[i]; + if ( + squares[a] && + squares[a] === squares[b] && + squares[a] === squares[c] + ) { + return squares[a]; + } + } + return null; +} +``` + +```css +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + margin: 20px; + padding: 0; +} + +.square { + background: #fff; + border: 1px solid #999; + float: left; + font-size: 24px; + font-weight: bold; + line-height: 34px; + height: 34px; + margin-right: -1px; + margin-top: -1px; + padding: 0; + text-align: center; + width: 34px; +} + +.board-row:after { + clear: both; + content: ""; + display: table; +} + +.status { + margin-bottom: 10px; +} +.game { + display: flex; + flex-direction: row; +} + +.game-info { + margin-left: 20px; +} +``` + +If you have extra time or want to practice your new React skills, here are some ideas for improvements that you could make to the tic-tac-toe game, listed in order of increasing difficulty: + +1. For the current move only, show "You are at move #..." instead of a button. +1. Rewrite `Board` to use two loops to make the squares instead of hardcoding them. +1. Add a toggle button that lets you sort the moves in either ascending or descending order. +1. When someone wins, highlight the three squares that caused the win (and when no one wins, display a message about the result being a draw). +1. Display the location for each move in the format (row, col) in the move history list. + +Throughout this tutorial, you've touched on React concepts including elements, components, props, and state. Now that you've seen how these concepts work when building a game, check out [Thinking in React](/learn/thinking-in-react) to see how the same React concepts work when build an app's UI. diff --git a/docs/src/learn/updating-arrays-in-state.md b/docs/src/learn/updating-arrays-in-state.md new file mode 100644 index 000000000..234b8b879 --- /dev/null +++ b/docs/src/learn/updating-arrays-in-state.md @@ -0,0 +1,1849 @@ +## Overview + +<p class="intro" markdown> + +Arrays are mutable in JavaScript, but you should treat them as immutable when you store them in state. Just like with objects, when you want to update an array stored in state, you need to create a new one (or make a copy of an existing one), and then set state to use the new array. + +</p> + +!!! summary "You will learn" + + - How to add, remove, or change items in an array in React state + - How to update an object inside of an array + - How to make array copying less repetitive with Immer + +## Updating arrays without mutation + +In JavaScript, arrays are just another kind of object. [Like with objects](/learn/updating-objects-in-state), **you should treat arrays in React state as read-only.** This means that you shouldn't reassign items inside an array like `arr[0] = 'bird'`, and you also shouldn't use methods that mutate the array, such as `push()` and `pop()`. + +Instead, every time you want to update an array, you'll want to pass a _new_ array to your state setting function. To do that, you can create a new array from the original array in your state by calling its non-mutating methods like `filter()` and `map()`. Then you can set your state to the resulting new array. + +Here is a reference table of common array operations. When dealing with arrays inside React state, you will need to avoid the methods in the left column, and instead prefer the methods in the right column: + +| | avoid (mutates the array) | prefer (returns a new array) | +| --- | --- | --- | +| adding | `push`, `unshift` | `concat`, `[...arr]` spread syntax ([example](#adding-to-an-array)) | +| removing | `pop`, `shift`, `splice` | `filter`, `slice` ([example](#removing-from-an-array)) | +| replacing | `splice`, `arr[i] = ...` assignment | `map` ([example](#replacing-items-in-an-array)) | +| sorting | `reverse`, `sort` | copy the array first ([example](#making-other-changes-to-an-array)) | + +Alternatively, you can [use Immer](#write-concise-update-logic-with-immer) which lets you use methods from both columns. + +<Pitfall> + +Unfortunately, [`slice`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice) and [`splice`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice) are named similarly but are very different: + +- `slice` lets you copy an array or a part of it. +- `splice` **mutates** the array (to insert or delete items). + +In React, you will be using `slice` (no `p`!) a lot more often because you don't want to mutate objects or arrays in state. [Updating Objects](/learn/updating-objects-in-state) explains what mutation is and why it's not recommended for state. + +</Pitfall> + +### Adding to an array + +`push()` will mutate an array, which you don't want: + +```js +import { useState } from "react"; + +let nextId = 0; + +export default function List() { + const [name, setName] = useState(""); + const [artists, setArtists] = useState([]); + + return ( + <> + <h1>Inspiring sculptors:</h1> + <input value={name} onChange={(e) => setName(e.target.value)} /> + <button + on_click={() => { + artists.push({ + id: nextId++, + name: name, + }); + }} + > + Add + </button> + <ul> + {artists.map((artist) => ( + <li key={artist.id}>{artist.name}</li> + ))} + </ul> + </> + ); +} +``` + +```css +button { + margin-left: 5px; +} +``` + +Instead, create a _new_ array which contains the existing items _and_ a new item at the end. There are multiple ways to do this, but the easiest one is to use the `...` [array spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_array_literals) syntax: + +```js +setArtists( + // Replace the state + [ + // with a new array + ...artists, // that contains all the old items + { id: nextId++, name: name }, // and one new item at the end + ] +); +``` + +Now it works correctly: + +```js +import { useState } from "react"; + +let nextId = 0; + +export default function List() { + const [name, setName] = useState(""); + const [artists, setArtists] = useState([]); + + return ( + <> + <h1>Inspiring sculptors:</h1> + <input value={name} onChange={(e) => setName(e.target.value)} /> + <button + on_click={() => { + setArtists([...artists, { id: nextId++, name: name }]); + }} + > + Add + </button> + <ul> + {artists.map((artist) => ( + <li key={artist.id}>{artist.name}</li> + ))} + </ul> + </> + ); +} +``` + +```css +button { + margin-left: 5px; +} +``` + +The array spread syntax also lets you prepend an item by placing it _before_ the original `...artists`: + +```js +setArtists([ + { id: nextId++, name: name }, + ...artists, // Put old items at the end +]); +``` + +In this way, spread can do the job of both `push()` by adding to the end of an array and `unshift()` by adding to the beginning of an array. Try it in the sandbox above! + +### Removing from an array + +The easiest way to remove an item from an array is to _filter it out_. In other words, you will produce a new array that will not contain that item. To do this, use the `filter` method, for example: + +```js +import { useState } from "react"; + +let initialArtists = [ + { id: 0, name: "Marta Colvin Andrade" }, + { id: 1, name: "Lamidi Olonade Fakeye" }, + { id: 2, name: "Louise Nevelson" }, +]; + +export default function List() { + const [artists, setArtists] = useState(initialArtists); + + return ( + <> + <h1>Inspiring sculptors:</h1> + <ul> + {artists.map((artist) => ( + <li key={artist.id}> + {artist.name}{" "} + <button + on_click={() => { + setArtists( + artists.filter((a) => a.id !== artist.id) + ); + }} + > + Delete + </button> + </li> + ))} + </ul> + </> + ); +} +``` + +Click the "Delete" button a few times, and look at its click handler. + +```js +setArtists(artists.filter((a) => a.id !== artist.id)); +``` + +Here, `artists.filter(a => a.id !== artist.id)` means "create an array that consists of those `artists` whose IDs are different from `artist.id`". In other words, each artist's "Delete" button will filter _that_ artist out of the array, and then request a re-render with the resulting array. Note that `filter` does not modify the original array. + +### Transforming an array + +If you want to change some or all items of the array, you can use `map()` to create a **new** array. The function you will pass to `map` can decide what to do with each item, based on its data or its index (or both). + +In this example, an array holds coordinates of two circles and a square. When you press the button, it moves only the circles down by 50 pixels. It does this by producing a new array of data using `map()`: + +```js +import { useState } from "react"; + +let initialShapes = [ + { id: 0, type: "circle", x: 50, y: 100 }, + { id: 1, type: "square", x: 150, y: 100 }, + { id: 2, type: "circle", x: 250, y: 100 }, +]; + +export default function ShapeEditor() { + const [shapes, setShapes] = useState(initialShapes); + + function handleClick() { + const nextShapes = shapes.map((shape) => { + if (shape.type === "square") { + // No change + return shape; + } else { + // Return a new circle 50px below + return { + ...shape, + y: shape.y + 50, + }; + } + }); + // Re-render with the new array + setShapes(nextShapes); + } + + return ( + <> + <button on_click={handleClick}>Move circles down!</button> + {shapes.map((shape) => ( + <div + key={shape.id} + style={{ + background: "purple", + position: "absolute", + left: shape.x, + top: shape.y, + borderRadius: shape.type === "circle" ? "50%" : "", + width: 20, + height: 20, + }} + /> + ))} + </> + ); +} +``` + +```css +body { + height: 300px; +} +``` + +### Replacing items in an array + +It is particularly common to want to replace one or more items in an array. Assignments like `arr[0] = 'bird'` are mutating the original array, so instead you'll want to use `map` for this as well. + +To replace an item, create a new array with `map`. Inside your `map` call, you will receive the item index as the second argument. Use it to decide whether to return the original item (the first argument) or something else: + +```js +import { useState } from "react"; + +let initialCounters = [0, 0, 0]; + +export default function CounterList() { + const [counters, setCounters] = useState(initialCounters); + + function handleIncrementClick(index) { + const nextCounters = counters.map((c, i) => { + if (i === index) { + // Increment the clicked counter + return c + 1; + } else { + // The rest haven't changed + return c; + } + }); + setCounters(nextCounters); + } + + return ( + <ul> + {counters.map((counter, i) => ( + <li key={i}> + {counter} + <button + on_click={() => { + handleIncrementClick(i); + }} + > + +1 + </button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +``` + +### Inserting into an array + +Sometimes, you may want to insert an item at a particular position that's neither at the beginning nor at the end. To do this, you can use the `...` array spread syntax together with the `slice()` method. The `slice()` method lets you cut a "slice" of the array. To insert an item, you will create an array that spreads the slice _before_ the insertion point, then the new item, and then the rest of the original array. + +In this example, the Insert button always inserts at the index `1`: + +```js +import { useState } from "react"; + +let nextId = 3; +const initialArtists = [ + { id: 0, name: "Marta Colvin Andrade" }, + { id: 1, name: "Lamidi Olonade Fakeye" }, + { id: 2, name: "Louise Nevelson" }, +]; + +export default function List() { + const [name, setName] = useState(""); + const [artists, setArtists] = useState(initialArtists); + + function handleClick() { + const insertAt = 1; // Could be any index + const nextArtists = [ + // Items before the insertion point: + ...artists.slice(0, insertAt), + // New item: + { id: nextId++, name: name }, + // Items after the insertion point: + ...artists.slice(insertAt), + ]; + setArtists(nextArtists); + setName(""); + } + + return ( + <> + <h1>Inspiring sculptors:</h1> + <input value={name} onChange={(e) => setName(e.target.value)} /> + <button on_click={handleClick}>Insert</button> + <ul> + {artists.map((artist) => ( + <li key={artist.id}>{artist.name}</li> + ))} + </ul> + </> + ); +} +``` + +```css +button { + margin-left: 5px; +} +``` + +### Making other changes to an array + +There are some things you can't do with the spread syntax and non-mutating methods like `map()` and `filter()` alone. For example, you may want to reverse or sort an array. The JavaScript `reverse()` and `sort()` methods are mutating the original array, so you can't use them directly. + +**However, you can copy the array first, and then make changes to it.** + +For example: + +```js +import { useState } from "react"; + +let nextId = 3; +const initialList = [ + { id: 0, title: "Big Bellies" }, + { id: 1, title: "Lunar Landscape" }, + { id: 2, title: "Terracotta Army" }, +]; + +export default function List() { + const [list, setList] = useState(initialList); + + function handleClick() { + const nextList = [...list]; + nextList.reverse(); + setList(nextList); + } + + return ( + <> + <button on_click={handleClick}>Reverse</button> + <ul> + {list.map((artwork) => ( + <li key={artwork.id}>{artwork.title}</li> + ))} + </ul> + </> + ); +} +``` + +Here, you use the `[...list]` spread syntax to create a copy of the original array first. Now that you have a copy, you can use mutating methods like `nextList.reverse()` or `nextList.sort()`, or even assign individual items with `nextList[0] = "something"`. + +However, **even if you copy an array, you can't mutate existing items _inside_ of it directly.** This is because copying is shallow--the new array will contain the same items as the original one. So if you modify an object inside the copied array, you are mutating the existing state. For example, code like this is a problem. + +```js +const nextList = [...list]; +nextList[0].seen = true; // Problem: mutates list[0] +setList(nextList); +``` + +Although `nextList` and `list` are two different arrays, **`nextList[0]` and `list[0]` point to the same object.** So by changing `nextList[0].seen`, you are also changing `list[0].seen`. This is a state mutation, which you should avoid! You can solve this issue in a similar way to [updating nested JavaScript objects](/learn/updating-objects-in-state#updating-a-nested-object)--by copying individual items you want to change instead of mutating them. Here's how. + +## Updating objects inside arrays + +Objects are not _really_ located "inside" arrays. They might appear to be "inside" in code, but each object in an array is a separate value, to which the array "points". This is why you need to be careful when changing nested fields like `list[0]`. Another person's artwork list may point to the same element of the array! + +**When updating nested state, you need to create copies from the point where you want to update, and all the way up to the top level.** Let's see how this works. + +In this example, two separate artwork lists have the same initial state. They are supposed to be isolated, but because of a mutation, their state is accidentally shared, and checking a box in one list affects the other list: + +```js +import { useState } from "react"; + +let nextId = 3; +const initialList = [ + { id: 0, title: "Big Bellies", seen: false }, + { id: 1, title: "Lunar Landscape", seen: false }, + { id: 2, title: "Terracotta Army", seen: true }, +]; + +export default function BucketList() { + const [myList, setMyList] = useState(initialList); + const [yourList, setYourList] = useState(initialList); + + function handleToggleMyList(artworkId, nextSeen) { + const myNextList = [...myList]; + const artwork = myNextList.find((a) => a.id === artworkId); + artwork.seen = nextSeen; + setMyList(myNextList); + } + + function handleToggleYourList(artworkId, nextSeen) { + const yourNextList = [...yourList]; + const artwork = yourNextList.find((a) => a.id === artworkId); + artwork.seen = nextSeen; + setYourList(yourNextList); + } + + return ( + <> + <h1>Art Bucket List</h1> + <h2>My list of art to see:</h2> + <ItemList artworks={myList} onToggle={handleToggleMyList} /> + <h2>Your list of art to see:</h2> + <ItemList artworks={yourList} onToggle={handleToggleYourList} /> + </> + ); +} + +function ItemList({ artworks, onToggle }) { + return ( + <ul> + {artworks.map((artwork) => ( + <li key={artwork.id}> + <label> + <input + type="checkbox" + checked={artwork.seen} + onChange={(e) => { + onToggle(artwork.id, e.target.checked); + }} + /> + {artwork.title} + </label> + </li> + ))} + </ul> + ); +} +``` + +The problem is in code like this: + +```js +const myNextList = [...myList]; +const artwork = myNextList.find((a) => a.id === artworkId); +artwork.seen = nextSeen; // Problem: mutates an existing item +setMyList(myNextList); +``` + +Although the `myNextList` array itself is new, the _items themselves_ are the same as in the original `myList` array. So changing `artwork.seen` changes the _original_ artwork item. That artwork item is also in `yourList`, which causes the bug. Bugs like this can be difficult to think about, but thankfully they disappear if you avoid mutating state. + +**You can use `map` to substitute an old item with its updated version without mutation.** + +```js +setMyList( + myList.map((artwork) => { + if (artwork.id === artworkId) { + // Create a *new* object with changes + return { ...artwork, seen: nextSeen }; + } else { + // No changes + return artwork; + } + }) +); +``` + +Here, `...` is the object spread syntax used to [create a copy of an object.](/learn/updating-objects-in-state#copying-objects-with-the-spread-syntax) + +With this approach, none of the existing state items are being mutated, and the bug is fixed: + +```js +import { useState } from "react"; + +let nextId = 3; +const initialList = [ + { id: 0, title: "Big Bellies", seen: false }, + { id: 1, title: "Lunar Landscape", seen: false }, + { id: 2, title: "Terracotta Army", seen: true }, +]; + +export default function BucketList() { + const [myList, setMyList] = useState(initialList); + const [yourList, setYourList] = useState(initialList); + + function handleToggleMyList(artworkId, nextSeen) { + setMyList( + myList.map((artwork) => { + if (artwork.id === artworkId) { + // Create a *new* object with changes + return { ...artwork, seen: nextSeen }; + } else { + // No changes + return artwork; + } + }) + ); + } + + function handleToggleYourList(artworkId, nextSeen) { + setYourList( + yourList.map((artwork) => { + if (artwork.id === artworkId) { + // Create a *new* object with changes + return { ...artwork, seen: nextSeen }; + } else { + // No changes + return artwork; + } + }) + ); + } + + return ( + <> + <h1>Art Bucket List</h1> + <h2>My list of art to see:</h2> + <ItemList artworks={myList} onToggle={handleToggleMyList} /> + <h2>Your list of art to see:</h2> + <ItemList artworks={yourList} onToggle={handleToggleYourList} /> + </> + ); +} + +function ItemList({ artworks, onToggle }) { + return ( + <ul> + {artworks.map((artwork) => ( + <li key={artwork.id}> + <label> + <input + type="checkbox" + checked={artwork.seen} + onChange={(e) => { + onToggle(artwork.id, e.target.checked); + }} + /> + {artwork.title} + </label> + </li> + ))} + </ul> + ); +} +``` + +In general, **you should only mutate objects that you have just created.** If you were inserting a _new_ artwork, you could mutate it, but if you're dealing with something that's already in state, you need to make a copy. + +### Write concise update logic with Immer + +Updating nested arrays without mutation can get a little bit repetitive. [Just as with objects](/learn/updating-objects-in-state#write-concise-update-logic-with-immer): + +- Generally, you shouldn't need to update state more than a couple of levels deep. If your state objects are very deep, you might want to [restructure them differently](/learn/choosing-the-state-structure#avoid-deeply-nested-state) so that they are flat. +- If you don't want to change your state structure, you might prefer to use [Immer](https://github.com/immerjs/use-immer), which lets you write using the convenient but mutating syntax and takes care of producing the copies for you. + +Here is the Art Bucket List example rewritten with Immer: + +```js +import { useState } from "react"; +import { useImmer } from "use-immer"; + +let nextId = 3; +const initialList = [ + { id: 0, title: "Big Bellies", seen: false }, + { id: 1, title: "Lunar Landscape", seen: false }, + { id: 2, title: "Terracotta Army", seen: true }, +]; + +export default function BucketList() { + const [myList, updateMyList] = useImmer(initialList); + const [yourList, updateYourList] = useImmer(initialList); + + function handleToggleMyList(id, nextSeen) { + updateMyList((draft) => { + const artwork = draft.find((a) => a.id === id); + artwork.seen = nextSeen; + }); + } + + function handleToggleYourList(artworkId, nextSeen) { + updateYourList((draft) => { + const artwork = draft.find((a) => a.id === artworkId); + artwork.seen = nextSeen; + }); + } + + return ( + <> + <h1>Art Bucket List</h1> + <h2>My list of art to see:</h2> + <ItemList artworks={myList} onToggle={handleToggleMyList} /> + <h2>Your list of art to see:</h2> + <ItemList artworks={yourList} onToggle={handleToggleYourList} /> + </> + ); +} + +function ItemList({ artworks, onToggle }) { + return ( + <ul> + {artworks.map((artwork) => ( + <li key={artwork.id}> + <label> + <input + type="checkbox" + checked={artwork.seen} + onChange={(e) => { + onToggle(artwork.id, e.target.checked); + }} + /> + {artwork.title} + </label> + </li> + ))} + </ul> + ); +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +Note how with Immer, **mutation like `artwork.seen = nextSeen` is now okay:** + +```js +updateMyTodos((draft) => { + const artwork = draft.find((a) => a.id === artworkId); + artwork.seen = nextSeen; +}); +``` + +This is because you're not mutating the _original_ state, but you're mutating a special `draft` object provided by Immer. Similarly, you can apply mutating methods like `push()` and `pop()` to the content of the `draft`. + +Behind the scenes, Immer always constructs the next state from scratch according to the changes that you've done to the `draft`. This keeps your event handlers very concise without ever mutating state. + +<Recap> + +- You can put arrays into state, but you can't change them. +- Instead of mutating an array, create a _new_ version of it, and update the state to it. +- You can use the `[...arr, newItem]` array spread syntax to create arrays with new items. +- You can use `filter()` and `map()` to create new arrays with filtered or transformed items. +- You can use Immer to keep your code concise. + +</Recap> + +<Challenges> + +#### Update an item in the shopping cart + +Fill in the `handleIncreaseClick` logic so that pressing "+" increases the corresponding number: + +```js +import { useState } from "react"; + +const initialProducts = [ + { + id: 0, + name: "Baklava", + count: 1, + }, + { + id: 1, + name: "Cheese", + count: 5, + }, + { + id: 2, + name: "Spaghetti", + count: 2, + }, +]; + +export default function ShoppingCart() { + const [products, setProducts] = useState(initialProducts); + + function handleIncreaseClick(productId) {} + + return ( + <ul> + {products.map((product) => ( + <li key={product.id}> + {product.name} (<b>{product.count}</b>) + <button + on_click={() => { + handleIncreaseClick(product.id); + }} + > + + + </button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +``` + +<Solution> + +You can use the `map` function to create a new array, and then use the `...` object spread syntax to create a copy of the changed object for the new array: + +```js +import { useState } from "react"; + +const initialProducts = [ + { + id: 0, + name: "Baklava", + count: 1, + }, + { + id: 1, + name: "Cheese", + count: 5, + }, + { + id: 2, + name: "Spaghetti", + count: 2, + }, +]; + +export default function ShoppingCart() { + const [products, setProducts] = useState(initialProducts); + + function handleIncreaseClick(productId) { + setProducts( + products.map((product) => { + if (product.id === productId) { + return { + ...product, + count: product.count + 1, + }; + } else { + return product; + } + }) + ); + } + + return ( + <ul> + {products.map((product) => ( + <li key={product.id}> + {product.name} (<b>{product.count}</b>) + <button + on_click={() => { + handleIncreaseClick(product.id); + }} + > + + + </button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +``` + +</Solution> + +#### Remove an item from the shopping cart + +This shopping cart has a working "+" button, but the "–" button doesn't do anything. You need to add an event handler to it so that pressing it decreases the `count` of the corresponding product. If you press "–" when the count is 1, the product should automatically get removed from the cart. Make sure it never shows 0. + +```js +import { useState } from "react"; + +const initialProducts = [ + { + id: 0, + name: "Baklava", + count: 1, + }, + { + id: 1, + name: "Cheese", + count: 5, + }, + { + id: 2, + name: "Spaghetti", + count: 2, + }, +]; + +export default function ShoppingCart() { + const [products, setProducts] = useState(initialProducts); + + function handleIncreaseClick(productId) { + setProducts( + products.map((product) => { + if (product.id === productId) { + return { + ...product, + count: product.count + 1, + }; + } else { + return product; + } + }) + ); + } + + return ( + <ul> + {products.map((product) => ( + <li key={product.id}> + {product.name} (<b>{product.count}</b>) + <button + on_click={() => { + handleIncreaseClick(product.id); + }} + > + + + </button> + <button>–</button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +``` + +<Solution> + +You can first use `map` to produce a new array, and then `filter` to remove products with a `count` set to `0`: + +```js +import { useState } from "react"; + +const initialProducts = [ + { + id: 0, + name: "Baklava", + count: 1, + }, + { + id: 1, + name: "Cheese", + count: 5, + }, + { + id: 2, + name: "Spaghetti", + count: 2, + }, +]; + +export default function ShoppingCart() { + const [products, setProducts] = useState(initialProducts); + + function handleIncreaseClick(productId) { + setProducts( + products.map((product) => { + if (product.id === productId) { + return { + ...product, + count: product.count + 1, + }; + } else { + return product; + } + }) + ); + } + + function handleDecreaseClick(productId) { + let nextProducts = products.map((product) => { + if (product.id === productId) { + return { + ...product, + count: product.count - 1, + }; + } else { + return product; + } + }); + nextProducts = nextProducts.filter((p) => p.count > 0); + setProducts(nextProducts); + } + + return ( + <ul> + {products.map((product) => ( + <li key={product.id}> + {product.name} (<b>{product.count}</b>) + <button + on_click={() => { + handleIncreaseClick(product.id); + }} + > + + + </button> + <button + on_click={() => { + handleDecreaseClick(product.id); + }} + > + – + </button> + </li> + ))} + </ul> + ); +} +``` + +```css +button { + margin: 5px; +} +``` + +</Solution> + +#### Fix the mutations using non-mutative methods + +In this example, all of the event handlers in `App.js` use mutation. As a result, editing and deleting todos doesn't work. Rewrite `handleAddTodo`, `handleChangeTodo`, and `handleDeleteTodo` to use the non-mutative methods: + +```js +import { useState } from "react"; +import AddTodo from "./AddTodo.js"; +import TaskList from "./TaskList.js"; + +let nextId = 3; +const initialTodos = [ + { id: 0, title: "Buy milk", done: true }, + { id: 1, title: "Eat tacos", done: false }, + { id: 2, title: "Brew tea", done: false }, +]; + +export default function TaskApp() { + const [todos, setTodos] = useState(initialTodos); + + function handleAddTodo(title) { + todos.push({ + id: nextId++, + title: title, + done: false, + }); + } + + function handleChangeTodo(nextTodo) { + const todo = todos.find((t) => t.id === nextTodo.id); + todo.title = nextTodo.title; + todo.done = nextTodo.done; + } + + function handleDeleteTodo(todoId) { + const index = todos.findIndex((t) => t.id === todoId); + todos.splice(index, 1); + } + + return ( + <> + <AddTodo onAddTodo={handleAddTodo} /> + <TaskList + todos={todos} + onChangeTodo={handleChangeTodo} + onDeleteTodo={handleDeleteTodo} + /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddTodo({ onAddTodo }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add todo" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddTodo(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ todos, onChangeTodo, onDeleteTodo }) { + return ( + <ul> + {todos.map((todo) => ( + <li key={todo.id}> + <Task + todo={todo} + onChange={onChangeTodo} + onDelete={onDeleteTodo} + /> + </li> + ))} + </ul> + ); +} + +function Task({ todo, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let todoContent; + if (isEditing) { + todoContent = ( + <> + <input + value={todo.title} + onChange={(e) => { + onChange({ + ...todo, + title: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + todoContent = ( + <> + {todo.title} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={todo.done} + onChange={(e) => { + onChange({ + ...todo, + done: e.target.checked, + }); + }} + /> + {todoContent} + <button on_click={() => onDelete(todo.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +<Solution> + +In `handleAddTodo`, you can use the array spread syntax. In `handleChangeTodo`, you can create a new array with `map`. In `handleDeleteTodo`, you can create a new array with `filter`. Now the list works correctly: + +```js +import { useState } from "react"; +import AddTodo from "./AddTodo.js"; +import TaskList from "./TaskList.js"; + +let nextId = 3; +const initialTodos = [ + { id: 0, title: "Buy milk", done: true }, + { id: 1, title: "Eat tacos", done: false }, + { id: 2, title: "Brew tea", done: false }, +]; + +export default function TaskApp() { + const [todos, setTodos] = useState(initialTodos); + + function handleAddTodo(title) { + setTodos([ + ...todos, + { + id: nextId++, + title: title, + done: false, + }, + ]); + } + + function handleChangeTodo(nextTodo) { + setTodos( + todos.map((t) => { + if (t.id === nextTodo.id) { + return nextTodo; + } else { + return t; + } + }) + ); + } + + function handleDeleteTodo(todoId) { + setTodos(todos.filter((t) => t.id !== todoId)); + } + + return ( + <> + <AddTodo onAddTodo={handleAddTodo} /> + <TaskList + todos={todos} + onChangeTodo={handleChangeTodo} + onDeleteTodo={handleDeleteTodo} + /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddTodo({ onAddTodo }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add todo" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddTodo(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ todos, onChangeTodo, onDeleteTodo }) { + return ( + <ul> + {todos.map((todo) => ( + <li key={todo.id}> + <Task + todo={todo} + onChange={onChangeTodo} + onDelete={onDeleteTodo} + /> + </li> + ))} + </ul> + ); +} + +function Task({ todo, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let todoContent; + if (isEditing) { + todoContent = ( + <> + <input + value={todo.title} + onChange={(e) => { + onChange({ + ...todo, + title: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + todoContent = ( + <> + {todo.title} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={todo.done} + onChange={(e) => { + onChange({ + ...todo, + done: e.target.checked, + }); + }} + /> + {todoContent} + <button on_click={() => onDelete(todo.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +</Solution> + +#### Fix the mutations using Immer + +This is the same example as in the previous challenge. This time, fix the mutations by using Immer. For your convenience, `useImmer` is already imported, so you need to change the `todos` state variable to use it. + +```js +import { useState } from "react"; +import { useImmer } from "use-immer"; +import AddTodo from "./AddTodo.js"; +import TaskList from "./TaskList.js"; + +let nextId = 3; +const initialTodos = [ + { id: 0, title: "Buy milk", done: true }, + { id: 1, title: "Eat tacos", done: false }, + { id: 2, title: "Brew tea", done: false }, +]; + +export default function TaskApp() { + const [todos, setTodos] = useState(initialTodos); + + function handleAddTodo(title) { + todos.push({ + id: nextId++, + title: title, + done: false, + }); + } + + function handleChangeTodo(nextTodo) { + const todo = todos.find((t) => t.id === nextTodo.id); + todo.title = nextTodo.title; + todo.done = nextTodo.done; + } + + function handleDeleteTodo(todoId) { + const index = todos.findIndex((t) => t.id === todoId); + todos.splice(index, 1); + } + + return ( + <> + <AddTodo onAddTodo={handleAddTodo} /> + <TaskList + todos={todos} + onChangeTodo={handleChangeTodo} + onDeleteTodo={handleDeleteTodo} + /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddTodo({ onAddTodo }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add todo" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddTodo(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ todos, onChangeTodo, onDeleteTodo }) { + return ( + <ul> + {todos.map((todo) => ( + <li key={todo.id}> + <Task + todo={todo} + onChange={onChangeTodo} + onDelete={onDeleteTodo} + /> + </li> + ))} + </ul> + ); +} + +function Task({ todo, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let todoContent; + if (isEditing) { + todoContent = ( + <> + <input + value={todo.title} + onChange={(e) => { + onChange({ + ...todo, + title: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + todoContent = ( + <> + {todo.title} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={todo.done} + onChange={(e) => { + onChange({ + ...todo, + done: e.target.checked, + }); + }} + /> + {todoContent} + <button on_click={() => onDelete(todo.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +<Solution> + +With Immer, you can write code in the mutative fashion, as long as you're only mutating parts of the `draft` that Immer gives you. Here, all mutations are performed on the `draft` so the code works: + +```js +import { useState } from "react"; +import { useImmer } from "use-immer"; +import AddTodo from "./AddTodo.js"; +import TaskList from "./TaskList.js"; + +let nextId = 3; +const initialTodos = [ + { id: 0, title: "Buy milk", done: true }, + { id: 1, title: "Eat tacos", done: false }, + { id: 2, title: "Brew tea", done: false }, +]; + +export default function TaskApp() { + const [todos, updateTodos] = useImmer(initialTodos); + + function handleAddTodo(title) { + updateTodos((draft) => { + draft.push({ + id: nextId++, + title: title, + done: false, + }); + }); + } + + function handleChangeTodo(nextTodo) { + updateTodos((draft) => { + const todo = draft.find((t) => t.id === nextTodo.id); + todo.title = nextTodo.title; + todo.done = nextTodo.done; + }); + } + + function handleDeleteTodo(todoId) { + updateTodos((draft) => { + const index = draft.findIndex((t) => t.id === todoId); + draft.splice(index, 1); + }); + } + + return ( + <> + <AddTodo onAddTodo={handleAddTodo} /> + <TaskList + todos={todos} + onChangeTodo={handleChangeTodo} + onDeleteTodo={handleDeleteTodo} + /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddTodo({ onAddTodo }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add todo" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddTodo(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ todos, onChangeTodo, onDeleteTodo }) { + return ( + <ul> + {todos.map((todo) => ( + <li key={todo.id}> + <Task + todo={todo} + onChange={onChangeTodo} + onDelete={onDeleteTodo} + /> + </li> + ))} + </ul> + ); +} + +function Task({ todo, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let todoContent; + if (isEditing) { + todoContent = ( + <> + <input + value={todo.title} + onChange={(e) => { + onChange({ + ...todo, + title: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + todoContent = ( + <> + {todo.title} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={todo.done} + onChange={(e) => { + onChange({ + ...todo, + done: e.target.checked, + }); + }} + /> + {todoContent} + <button on_click={() => onDelete(todo.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +You can also mix and match the mutative and non-mutative approaches with Immer. + +For example, in this version `handleAddTodo` is implemented by mutating the Immer `draft`, while `handleChangeTodo` and `handleDeleteTodo` use the non-mutative `map` and `filter` methods: + +```js +import { useState } from "react"; +import { useImmer } from "use-immer"; +import AddTodo from "./AddTodo.js"; +import TaskList from "./TaskList.js"; + +let nextId = 3; +const initialTodos = [ + { id: 0, title: "Buy milk", done: true }, + { id: 1, title: "Eat tacos", done: false }, + { id: 2, title: "Brew tea", done: false }, +]; + +export default function TaskApp() { + const [todos, updateTodos] = useImmer(initialTodos); + + function handleAddTodo(title) { + updateTodos((draft) => { + draft.push({ + id: nextId++, + title: title, + done: false, + }); + }); + } + + function handleChangeTodo(nextTodo) { + updateTodos( + todos.map((todo) => { + if (todo.id === nextTodo.id) { + return nextTodo; + } else { + return todo; + } + }) + ); + } + + function handleDeleteTodo(todoId) { + updateTodos(todos.filter((t) => t.id !== todoId)); + } + + return ( + <> + <AddTodo onAddTodo={handleAddTodo} /> + <TaskList + todos={todos} + onChangeTodo={handleChangeTodo} + onDeleteTodo={handleDeleteTodo} + /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddTodo({ onAddTodo }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add todo" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + on_click={() => { + setTitle(""); + onAddTodo(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ todos, onChangeTodo, onDeleteTodo }) { + return ( + <ul> + {todos.map((todo) => ( + <li key={todo.id}> + <Task + todo={todo} + onChange={onChangeTodo} + onDelete={onDeleteTodo} + /> + </li> + ))} + </ul> + ); +} + +function Task({ todo, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let todoContent; + if (isEditing) { + todoContent = ( + <> + <input + value={todo.title} + onChange={(e) => { + onChange({ + ...todo, + title: e.target.value, + }); + }} + /> + <button on_click={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + todoContent = ( + <> + {todo.title} + <button on_click={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={todo.done} + onChange={(e) => { + onChange({ + ...todo, + done: e.target.checked, + }); + }} + /> + {todoContent} + <button on_click={() => onDelete(todo.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +With Immer, you can pick the style that feels the most natural for each separate case. + +</Solution> + +</Challenges> diff --git a/docs/src/learn/updating-objects-in-state.md b/docs/src/learn/updating-objects-in-state.md new file mode 100644 index 000000000..5603b50fa --- /dev/null +++ b/docs/src/learn/updating-objects-in-state.md @@ -0,0 +1,1565 @@ +## Overview + +<p class="intro" markdown> + +State can hold any kind of JavaScript value, including objects. But you shouldn't change objects that you hold in the React state directly. Instead, when you want to update an object, you need to create a new one (or make a copy of an existing one), and then set the state to use that copy. + +</p> + +!!! summary "You will learn" + + - How to correctly update an object in React state + - How to update a nested object without mutating it + - What immutability is, and how not to break it + - How to make object copying less repetitive with Immer + +## What's a mutation? + +You can store any kind of JavaScript value in state. + +```js +const [x, setX] = useState(0); +``` + +So far you've been working with numbers, strings, and booleans. These kinds of JavaScript values are "immutable", meaning unchangeable or "read-only". You can trigger a re-render to _replace_ a value: + +```js +setX(5); +``` + +The `x` state changed from `0` to `5`, but the _number `0` itself_ did not change. It's not possible to make any changes to the built-in primitive values like numbers, strings, and booleans in JavaScript. + +Now consider an object in state: + +```js +const [position, setPosition] = useState({ x: 0, y: 0 }); +``` + +Technically, it is possible to change the contents of _the object itself_. **This is called a mutation:** + +```js +position.x = 5; +``` + +However, although objects in React state are technically mutable, you should treat them **as if** they were immutable--like numbers, booleans, and strings. Instead of mutating them, you should always replace them. + +## Treat state as read-only + +In other words, you should **treat any JavaScript object that you put into state as read-only.** + +This example holds an object in state to represent the current pointer position. The red dot is supposed to move when you touch or move the cursor over the preview area. But the dot stays in the initial position: + +```js +import { useState } from "react"; +export default function MovingDot() { + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + return ( + <div + onPointerMove={(e) => { + position.x = e.clientX; + position.y = e.clientY; + }} + style={{ + position: "relative", + width: "100vw", + height: "100vh", + }} + > + <div + style={{ + position: "absolute", + backgroundColor: "red", + borderRadius: "50%", + transform: `translate(${position.x}px, ${position.y}px)`, + left: -10, + top: -10, + width: 20, + height: 20, + }} + /> + </div> + ); +} +``` + +```css +body { + margin: 0; + padding: 0; + height: 250px; +} +``` + +The problem is with this bit of code. + +```js +onPointerMove={e => { + position.x = e.clientX; + position.y = e.clientY; +}} +``` + +This code modifies the object assigned to `position` from [the previous render.](/learn/state-as-a-snapshot#rendering-takes-a-snapshot-in-time) But without using the state setting function, React has no idea that object has changed. So React does not do anything in response. It's like trying to change the order after you've already eaten the meal. While mutating state can work in some cases, we don't recommend it. You should treat the state value you have access to in a render as read-only. + +To actually [trigger a re-render](/learn/state-as-a-snapshot#setting-state-triggers-renders) in this case, **create a _new_ object and pass it to the state setting function:** + +```js +onPointerMove={e => { + setPosition({ + x: e.clientX, + y: e.clientY + }); +}} +``` + +With `setPosition`, you're telling React: + +- Replace `position` with this new object +- And render this component again + +Notice how the red dot now follows your pointer when you touch or hover over the preview area: + +```js +import { useState } from "react"; +export default function MovingDot() { + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + return ( + <div + onPointerMove={(e) => { + setPosition({ + x: e.clientX, + y: e.clientY, + }); + }} + style={{ + position: "relative", + width: "100vw", + height: "100vh", + }} + > + <div + style={{ + position: "absolute", + backgroundColor: "red", + borderRadius: "50%", + transform: `translate(${position.x}px, ${position.y}px)`, + left: -10, + top: -10, + width: 20, + height: 20, + }} + /> + </div> + ); +} +``` + +```css +body { + margin: 0; + padding: 0; + height: 250px; +} +``` + +<DeepDive> + +#### Local mutation is fine + +Code like this is a problem because it modifies an _existing_ object in state: + +```js +position.x = e.clientX; +position.y = e.clientY; +``` + +But code like this is **absolutely fine** because you're mutating a fresh object you have _just created_: + +```js +const nextPosition = {}; +nextPosition.x = e.clientX; +nextPosition.y = e.clientY; +setPosition(nextPosition); +``` + +In fact, it is completely equivalent to writing this: + +```js +setPosition({ + x: e.clientX, + y: e.clientY, +}); +``` + +Mutation is only a problem when you change _existing_ objects that are already in state. Mutating an object you've just created is okay because _no other code references it yet._ Changing it isn't going to accidentally impact something that depends on it. This is called a "local mutation". You can even do local mutation [while rendering.](/learn/keeping-components-pure#local-mutation-your-components-little-secret) Very convenient and completely okay! + +</DeepDive> + +## Copying objects with the spread syntax + +In the previous example, the `position` object is always created fresh from the current cursor position. But often, you will want to include _existing_ data as a part of the new object you're creating. For example, you may want to update _only one_ field in a form, but keep the previous values for all other fields. + +These input fields don't work because the `onChange` handlers mutate the state: + +```js +import { useState } from "react"; + +export default function Form() { + const [person, setPerson] = useState({ + firstName: "Barbara", + lastName: "Hepworth", + email: "bhepworth@sculpture.com", + }); + + function handleFirstNameChange(e) { + person.firstName = e.target.value; + } + + function handleLastNameChange(e) { + person.lastName = e.target.value; + } + + function handleEmailChange(e) { + person.email = e.target.value; + } + + return ( + <> + <label> + First name: + <input + value={person.firstName} + onChange={handleFirstNameChange} + /> + </label> + <label> + Last name: + <input + value={person.lastName} + onChange={handleLastNameChange} + /> + </label> + <label> + Email: + <input value={person.email} onChange={handleEmailChange} /> + </label> + <p> + {person.firstName} {person.lastName} ({person.email}) + </p> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +``` + +For example, this line mutates the state from a past render: + +```js +person.firstName = e.target.value; +``` + +The reliable way to get the behavior you're looking for is to create a new object and pass it to `setPerson`. But here, you want to also **copy the existing data into it** because only one of the fields has changed: + +```js +setPerson({ + firstName: e.target.value, // New first name from the input + lastName: person.lastName, + email: person.email, +}); +``` + +You can use the `...` [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals) syntax so that you don't need to copy every property separately. + +```js +setPerson({ + ...person, // Copy the old fields + firstName: e.target.value, // But override this one +}); +``` + +Now the form works! + +Notice how you didn't declare a separate state variable for each input field. For large forms, keeping all data grouped in an object is very convenient--as long as you update it correctly! + +```js +import { useState } from "react"; + +export default function Form() { + const [person, setPerson] = useState({ + firstName: "Barbara", + lastName: "Hepworth", + email: "bhepworth@sculpture.com", + }); + + function handleFirstNameChange(e) { + setPerson({ + ...person, + firstName: e.target.value, + }); + } + + function handleLastNameChange(e) { + setPerson({ + ...person, + lastName: e.target.value, + }); + } + + function handleEmailChange(e) { + setPerson({ + ...person, + email: e.target.value, + }); + } + + return ( + <> + <label> + First name: + <input + value={person.firstName} + onChange={handleFirstNameChange} + /> + </label> + <label> + Last name: + <input + value={person.lastName} + onChange={handleLastNameChange} + /> + </label> + <label> + Email: + <input value={person.email} onChange={handleEmailChange} /> + </label> + <p> + {person.firstName} {person.lastName} ({person.email}) + </p> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +``` + +Note that the `...` spread syntax is "shallow"--it only copies things one level deep. This makes it fast, but it also means that if you want to update a nested property, you'll have to use it more than once. + +<DeepDive> + +#### Using a single event handler for multiple fields + +You can also use the `[` and `]` braces inside your object definition to specify a property with dynamic name. Here is the same example, but with a single event handler instead of three different ones: + +```js +import { useState } from "react"; + +export default function Form() { + const [person, setPerson] = useState({ + firstName: "Barbara", + lastName: "Hepworth", + email: "bhepworth@sculpture.com", + }); + + function handleChange(e) { + setPerson({ + ...person, + [e.target.name]: e.target.value, + }); + } + + return ( + <> + <label> + First name: + <input + name="firstName" + value={person.firstName} + onChange={handleChange} + /> + </label> + <label> + Last name: + <input + name="lastName" + value={person.lastName} + onChange={handleChange} + /> + </label> + <label> + Email: + <input + name="email" + value={person.email} + onChange={handleChange} + /> + </label> + <p> + {person.firstName} {person.lastName} ({person.email}) + </p> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +``` + +Here, `e.target.name` refers to the `name` property given to the `<input>` DOM element. + +</DeepDive> + +## Updating a nested object + +Consider a nested object structure like this: + +```js +const [person, setPerson] = useState({ + name: "Niki de Saint Phalle", + artwork: { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", + }, +}); +``` + +If you wanted to update `person.artwork.city`, it's clear how to do it with mutation: + +```js +person.artwork.city = "New Delhi"; +``` + +But in React, you treat state as immutable! In order to change `city`, you would first need to produce the new `artwork` object (pre-populated with data from the previous one), and then produce the new `person` object which points at the new `artwork`: + +```js +const nextArtwork = { ...person.artwork, city: "New Delhi" }; +const nextPerson = { ...person, artwork: nextArtwork }; +setPerson(nextPerson); +``` + +Or, written as a single function call: + +```js +setPerson({ + ...person, // Copy other fields + artwork: { + // but replace the artwork + ...person.artwork, // with the same one + city: "New Delhi", // but in New Delhi! + }, +}); +``` + +This gets a bit wordy, but it works fine for many cases: + +```js +import { useState } from "react"; + +export default function Form() { + const [person, setPerson] = useState({ + name: "Niki de Saint Phalle", + artwork: { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", + }, + }); + + function handleNameChange(e) { + setPerson({ + ...person, + name: e.target.value, + }); + } + + function handleTitleChange(e) { + setPerson({ + ...person, + artwork: { + ...person.artwork, + title: e.target.value, + }, + }); + } + + function handleCityChange(e) { + setPerson({ + ...person, + artwork: { + ...person.artwork, + city: e.target.value, + }, + }); + } + + function handleImageChange(e) { + setPerson({ + ...person, + artwork: { + ...person.artwork, + image: e.target.value, + }, + }); + } + + return ( + <> + <label> + Name: + <input value={person.name} onChange={handleNameChange} /> + </label> + <label> + Title: + <input + value={person.artwork.title} + onChange={handleTitleChange} + /> + </label> + <label> + City: + <input + value={person.artwork.city} + onChange={handleCityChange} + /> + </label> + <label> + Image: + <input + value={person.artwork.image} + onChange={handleImageChange} + /> + </label> + <p> + <i>{person.artwork.title}</i> + {" by "} + {person.name} + <br /> + (located in {person.artwork.city}) + </p> + <img src={person.artwork.image} alt={person.artwork.title} /> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +img { + width: 200px; + height: 200px; +} +``` + +<DeepDive> + +#### Objects are not really nested + +An object like this appears "nested" in code: + +```js +let obj = { + name: "Niki de Saint Phalle", + artwork: { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", + }, +}; +``` + +However, "nesting" is an inaccurate way to think about how objects behave. When the code executes, there is no such thing as a "nested" object. You are really looking at two different objects: + +```js +let obj1 = { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", +}; + +let obj2 = { + name: "Niki de Saint Phalle", + artwork: obj1, +}; +``` + +The `obj1` object is not "inside" `obj2`. For example, `obj3` could "point" at `obj1` too: + +```js +let obj1 = { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", +}; + +let obj2 = { + name: "Niki de Saint Phalle", + artwork: obj1, +}; + +let obj3 = { + name: "Copycat", + artwork: obj1, +}; +``` + +If you were to mutate `obj3.artwork.city`, it would affect both `obj2.artwork.city` and `obj1.city`. This is because `obj3.artwork`, `obj2.artwork`, and `obj1` are the same object. This is difficult to see when you think of objects as "nested". Instead, they are separate objects "pointing" at each other with properties. + +</DeepDive> + +### Write concise update logic with Immer + +If your state is deeply nested, you might want to consider [flattening it.](/learn/choosing-the-state-structure#avoid-deeply-nested-state) But, if you don't want to change your state structure, you might prefer a shortcut to nested spreads. [Immer](https://github.com/immerjs/use-immer) is a popular library that lets you write using the convenient but mutating syntax and takes care of producing the copies for you. With Immer, the code you write looks like you are "breaking the rules" and mutating an object: + +```js +updatePerson((draft) => { + draft.artwork.city = "Lagos"; +}); +``` + +But unlike a regular mutation, it doesn't overwrite the past state! + +<DeepDive> + +#### How does Immer work? + +The `draft` provided by Immer is a special type of object, called a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy), that "records" what you do with it. This is why you can mutate it freely as much as you like! Under the hood, Immer figures out which parts of the `draft` have been changed, and produces a completely new object that contains your edits. + +</DeepDive> + +To try Immer: + +1. Run `npm install use-immer` to add Immer as a dependency +2. Then replace `import { useState } from 'react'` with `import { useImmer } from 'use-immer'` + +Here is the above example converted to Immer: + +```js +import { useImmer } from "use-immer"; + +export default function Form() { + const [person, updatePerson] = useImmer({ + name: "Niki de Saint Phalle", + artwork: { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", + }, + }); + + function handleNameChange(e) { + updatePerson((draft) => { + draft.name = e.target.value; + }); + } + + function handleTitleChange(e) { + updatePerson((draft) => { + draft.artwork.title = e.target.value; + }); + } + + function handleCityChange(e) { + updatePerson((draft) => { + draft.artwork.city = e.target.value; + }); + } + + function handleImageChange(e) { + updatePerson((draft) => { + draft.artwork.image = e.target.value; + }); + } + + return ( + <> + <label> + Name: + <input value={person.name} onChange={handleNameChange} /> + </label> + <label> + Title: + <input + value={person.artwork.title} + onChange={handleTitleChange} + /> + </label> + <label> + City: + <input + value={person.artwork.city} + onChange={handleCityChange} + /> + </label> + <label> + Image: + <input + value={person.artwork.image} + onChange={handleImageChange} + /> + </label> + <p> + <i>{person.artwork.title}</i> + {" by "} + {person.name} + <br /> + (located in {person.artwork.city}) + </p> + <img src={person.artwork.image} alt={person.artwork.title} /> + </> + ); +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +img { + width: 200px; + height: 200px; +} +``` + +Notice how much more concise the event handlers have become. You can mix and match `useState` and `useImmer` in a single component as much as you like. Immer is a great way to keep the update handlers concise, especially if there's nesting in your state, and copying objects leads to repetitive code. + +<DeepDive> + +#### Why is mutating state not recommended in React? + +There are a few reasons: + +- **Debugging:** If you use `console.log` and don't mutate state, your past logs won't get clobbered by the more recent state changes. So you can clearly see how state has changed between renders. +- **Optimizations:** Common React [optimization strategies](/reference/react/memo) rely on skipping work if previous props or state are the same as the next ones. If you never mutate state, it is very fast to check whether there were any changes. If `prevObj === obj`, you can be sure that nothing could have changed inside of it. +- **New Features:** The new React features we're building rely on state being [treated like a snapshot.](/learn/state-as-a-snapshot) If you're mutating past versions of state, that may prevent you from using the new features. +- **Requirement Changes:** Some application features, like implementing Undo/Redo, showing a history of changes, or letting the user reset a form to earlier values, are easier to do when nothing is mutated. This is because you can keep past copies of state in memory, and reuse them when appropriate. If you start with a mutative approach, features like this can be difficult to add later on. +- **Simpler Implementation:** Because React does not rely on mutation, it does not need to do anything special with your objects. It does not need to hijack their properties, always wrap them into Proxies, or do other work at initialization as many "reactive" solutions do. This is also why React lets you put any object into state--no matter how large--without additional performance or correctness pitfalls. + +In practice, you can often "get away" with mutating state in React, but we strongly advise you not to do that so that you can use new React features developed with this approach in mind. Future contributors and perhaps even your future self will thank you! + +</DeepDive> + +<Recap> + +- Treat all state in React as immutable. +- When you store objects in state, mutating them will not trigger renders and will change the state in previous render "snapshots". +- Instead of mutating an object, create a _new_ version of it, and trigger a re-render by setting state to it. +- You can use the `{...obj, something: 'newValue'}` object spread syntax to create copies of objects. +- Spread syntax is shallow: it only copies one level deep. +- To update a nested object, you need to create copies all the way up from the place you're updating. +- To reduce repetitive copying code, use Immer. + +</Recap> + +<Challenges> + +#### Fix incorrect state updates + +This form has a few bugs. Click the button that increases the score a few times. Notice that it does not increase. Then edit the first name, and notice that the score has suddenly "caught up" with your changes. Finally, edit the last name, and notice that the score has disappeared completely. + +Your task is to fix all of these bugs. As you fix them, explain why each of them happens. + +```js +import { useState } from "react"; + +export default function Scoreboard() { + const [player, setPlayer] = useState({ + firstName: "Ranjani", + lastName: "Shettar", + score: 10, + }); + + function handlePlusClick() { + player.score++; + } + + function handleFirstNameChange(e) { + setPlayer({ + ...player, + firstName: e.target.value, + }); + } + + function handleLastNameChange(e) { + setPlayer({ + lastName: e.target.value, + }); + } + + return ( + <> + <label> + Score: <b>{player.score}</b>{" "} + <button on_click={handlePlusClick}>+1</button> + </label> + <label> + First name: + <input + value={player.firstName} + onChange={handleFirstNameChange} + /> + </label> + <label> + Last name: + <input + value={player.lastName} + onChange={handleLastNameChange} + /> + </label> + </> + ); +} +``` + +```css +label { + display: block; + margin-bottom: 10px; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +``` + +<Solution> + +Here is a version with both bugs fixed: + +```js +import { useState } from "react"; + +export default function Scoreboard() { + const [player, setPlayer] = useState({ + firstName: "Ranjani", + lastName: "Shettar", + score: 10, + }); + + function handlePlusClick() { + setPlayer({ + ...player, + score: player.score + 1, + }); + } + + function handleFirstNameChange(e) { + setPlayer({ + ...player, + firstName: e.target.value, + }); + } + + function handleLastNameChange(e) { + setPlayer({ + ...player, + lastName: e.target.value, + }); + } + + return ( + <> + <label> + Score: <b>{player.score}</b>{" "} + <button on_click={handlePlusClick}>+1</button> + </label> + <label> + First name: + <input + value={player.firstName} + onChange={handleFirstNameChange} + /> + </label> + <label> + Last name: + <input + value={player.lastName} + onChange={handleLastNameChange} + /> + </label> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +``` + +The problem with `handlePlusClick` was that it mutated the `player` object. As a result, React did not know that there's a reason to re-render, and did not update the score on the screen. This is why, when you edited the first name, the state got updated, triggering a re-render which _also_ updated the score on the screen. + +The problem with `handleLastNameChange` was that it did not copy the existing `...player` fields into the new object. This is why the score got lost after you edited the last name. + +</Solution> + +#### Find and fix the mutation + +There is a draggable box on a static background. You can change the box's color using the select input. + +But there is a bug. If you move the box first, and then change its color, the background (which isn't supposed to move!) will "jump" to the box position. But this should not happen: the `Background`'s `position` prop is set to `initialPosition`, which is `{ x: 0, y: 0 }`. Why is the background moving after the color change? + +Find the bug and fix it. + +<Hint> + +If something unexpected changes, there is a mutation. Find the mutation in `App.js` and fix it. + +</Hint> + +```js +import { useState } from "react"; +import Background from "./Background.js"; +import Box from "./Box.js"; + +const initialPosition = { + x: 0, + y: 0, +}; + +export default function Canvas() { + const [shape, setShape] = useState({ + color: "orange", + position: initialPosition, + }); + + function handleMove(dx, dy) { + shape.position.x += dx; + shape.position.y += dy; + } + + function handleColorChange(e) { + setShape({ + ...shape, + color: e.target.value, + }); + } + + return ( + <> + <select value={shape.color} onChange={handleColorChange}> + <option value="orange">orange</option> + <option value="lightpink">lightpink</option> + <option value="aliceblue">aliceblue</option> + </select> + <Background position={initialPosition} /> + <Box + color={shape.color} + position={shape.position} + onMove={handleMove} + > + Drag me! + </Box> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Box({ children, color, position, onMove }) { + const [lastCoordinates, setLastCoordinates] = useState(null); + + function handlePointerDown(e) { + e.target.setPointerCapture(e.pointerId); + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + } + + function handlePointerMove(e) { + if (lastCoordinates) { + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + const dx = e.clientX - lastCoordinates.x; + const dy = e.clientY - lastCoordinates.y; + onMove(dx, dy); + } + } + + function handlePointerUp(e) { + setLastCoordinates(null); + } + + return ( + <div + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + style={{ + width: 100, + height: 100, + cursor: "grab", + backgroundColor: color, + position: "absolute", + border: "1px solid black", + display: "flex", + justifyContent: "center", + alignItems: "center", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + }} + > + {children} + </div> + ); +} +``` + +```js +export default function Background({ position }) { + return ( + <div + style={{ + position: "absolute", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + width: 250, + height: 250, + backgroundColor: "rgba(200, 200, 0, 0.2)", + }} + /> + ); +} +``` + +```css +body { + height: 280px; +} +select { + margin-bottom: 10px; +} +``` + +<Solution> + +The problem was in the mutation inside `handleMove`. It mutated `shape.position`, but that's the same object that `initialPosition` points at. This is why both the shape and the background move. (It's a mutation, so the change doesn't reflect on the screen until an unrelated update--the color change--triggers a re-render.) + +The fix is to remove the mutation from `handleMove`, and use the spread syntax to copy the shape. Note that `+=` is a mutation, so you need to rewrite it to use a regular `+` operation. + +```js +import { useState } from "react"; +import Background from "./Background.js"; +import Box from "./Box.js"; + +const initialPosition = { + x: 0, + y: 0, +}; + +export default function Canvas() { + const [shape, setShape] = useState({ + color: "orange", + position: initialPosition, + }); + + function handleMove(dx, dy) { + setShape({ + ...shape, + position: { + x: shape.position.x + dx, + y: shape.position.y + dy, + }, + }); + } + + function handleColorChange(e) { + setShape({ + ...shape, + color: e.target.value, + }); + } + + return ( + <> + <select value={shape.color} onChange={handleColorChange}> + <option value="orange">orange</option> + <option value="lightpink">lightpink</option> + <option value="aliceblue">aliceblue</option> + </select> + <Background position={initialPosition} /> + <Box + color={shape.color} + position={shape.position} + onMove={handleMove} + > + Drag me! + </Box> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Box({ children, color, position, onMove }) { + const [lastCoordinates, setLastCoordinates] = useState(null); + + function handlePointerDown(e) { + e.target.setPointerCapture(e.pointerId); + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + } + + function handlePointerMove(e) { + if (lastCoordinates) { + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + const dx = e.clientX - lastCoordinates.x; + const dy = e.clientY - lastCoordinates.y; + onMove(dx, dy); + } + } + + function handlePointerUp(e) { + setLastCoordinates(null); + } + + return ( + <div + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + style={{ + width: 100, + height: 100, + cursor: "grab", + backgroundColor: color, + position: "absolute", + border: "1px solid black", + display: "flex", + justifyContent: "center", + alignItems: "center", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + }} + > + {children} + </div> + ); +} +``` + +```js +export default function Background({ position }) { + return ( + <div + style={{ + position: "absolute", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + width: 250, + height: 250, + backgroundColor: "rgba(200, 200, 0, 0.2)", + }} + /> + ); +} +``` + +```css +body { + height: 280px; +} +select { + margin-bottom: 10px; +} +``` + +</Solution> + +#### Update an object with Immer + +This is the same buggy example as in the previous challenge. This time, fix the mutation by using Immer. For your convenience, `useImmer` is already imported, so you need to change the `shape` state variable to use it. + +```js +import { useState } from "react"; +import { useImmer } from "use-immer"; +import Background from "./Background.js"; +import Box from "./Box.js"; + +const initialPosition = { + x: 0, + y: 0, +}; + +export default function Canvas() { + const [shape, setShape] = useState({ + color: "orange", + position: initialPosition, + }); + + function handleMove(dx, dy) { + shape.position.x += dx; + shape.position.y += dy; + } + + function handleColorChange(e) { + setShape({ + ...shape, + color: e.target.value, + }); + } + + return ( + <> + <select value={shape.color} onChange={handleColorChange}> + <option value="orange">orange</option> + <option value="lightpink">lightpink</option> + <option value="aliceblue">aliceblue</option> + </select> + <Background position={initialPosition} /> + <Box + color={shape.color} + position={shape.position} + onMove={handleMove} + > + Drag me! + </Box> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Box({ children, color, position, onMove }) { + const [lastCoordinates, setLastCoordinates] = useState(null); + + function handlePointerDown(e) { + e.target.setPointerCapture(e.pointerId); + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + } + + function handlePointerMove(e) { + if (lastCoordinates) { + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + const dx = e.clientX - lastCoordinates.x; + const dy = e.clientY - lastCoordinates.y; + onMove(dx, dy); + } + } + + function handlePointerUp(e) { + setLastCoordinates(null); + } + + return ( + <div + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + style={{ + width: 100, + height: 100, + cursor: "grab", + backgroundColor: color, + position: "absolute", + border: "1px solid black", + display: "flex", + justifyContent: "center", + alignItems: "center", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + }} + > + {children} + </div> + ); +} +``` + +```js +export default function Background({ position }) { + return ( + <div + style={{ + position: "absolute", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + width: 250, + height: 250, + backgroundColor: "rgba(200, 200, 0, 0.2)", + }} + /> + ); +} +``` + +```css +body { + height: 280px; +} +select { + margin-bottom: 10px; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +<Solution> + +This is the solution rewritten with Immer. Notice how the event handlers are written in a mutating fashion, but the bug does not occur. This is because under the hood, Immer never mutates the existing objects. + +```js +import { useImmer } from "use-immer"; +import Background from "./Background.js"; +import Box from "./Box.js"; + +const initialPosition = { + x: 0, + y: 0, +}; + +export default function Canvas() { + const [shape, updateShape] = useImmer({ + color: "orange", + position: initialPosition, + }); + + function handleMove(dx, dy) { + updateShape((draft) => { + draft.position.x += dx; + draft.position.y += dy; + }); + } + + function handleColorChange(e) { + updateShape((draft) => { + draft.color = e.target.value; + }); + } + + return ( + <> + <select value={shape.color} onChange={handleColorChange}> + <option value="orange">orange</option> + <option value="lightpink">lightpink</option> + <option value="aliceblue">aliceblue</option> + </select> + <Background position={initialPosition} /> + <Box + color={shape.color} + position={shape.position} + onMove={handleMove} + > + Drag me! + </Box> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function Box({ children, color, position, onMove }) { + const [lastCoordinates, setLastCoordinates] = useState(null); + + function handlePointerDown(e) { + e.target.setPointerCapture(e.pointerId); + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + } + + function handlePointerMove(e) { + if (lastCoordinates) { + setLastCoordinates({ + x: e.clientX, + y: e.clientY, + }); + const dx = e.clientX - lastCoordinates.x; + const dy = e.clientY - lastCoordinates.y; + onMove(dx, dy); + } + } + + function handlePointerUp(e) { + setLastCoordinates(null); + } + + return ( + <div + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + style={{ + width: 100, + height: 100, + cursor: "grab", + backgroundColor: color, + position: "absolute", + border: "1px solid black", + display: "flex", + justifyContent: "center", + alignItems: "center", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + }} + > + {children} + </div> + ); +} +``` + +```js +export default function Background({ position }) { + return ( + <div + style={{ + position: "absolute", + transform: `translate( + ${position.x}px, + ${position.y}px + )`, + width: 250, + height: 250, + backgroundColor: "rgba(200, 200, 0, 0.2)", + }} + /> + ); +} +``` + +```css +body { + height: 280px; +} +select { + margin-bottom: 10px; +} +``` + +```json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/learn/vdom-mutations.md b/docs/src/learn/vdom-mutations.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/learn/writing-markup-with-psx.md b/docs/src/learn/writing-markup-with-psx.md new file mode 100644 index 000000000..292ba4c6c --- /dev/null +++ b/docs/src/learn/writing-markup-with-psx.md @@ -0,0 +1,327 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. + + See [this issue](https://github.com/reactive-python/reactpy/issues/918) for more details. + +<!-- ## Overview + +<p class="intro" markdown> + +_PSX_ is a syntax extension for JavaScript that lets you write HTML-like markup inside a JavaScript file. Although there are other ways to write components, most React developers prefer the conciseness of PSX, and most codebases use it. + +</p> + +!!! summary "You will learn" + + - Why React mixes markup with rendering logic + - How PSX is different from HTML + - How to display information with PSX + +## PSX: Putting markup into Python + +The Web has been built on HTML, CSS, and JavaScript. For many years, web developers kept content in HTML, design in CSS, and logic in JavaScript—often in separate files! Content was marked up inside HTML while the page's logic lived separately in JavaScript: --> + +<!-- TODO: Diagram --> + +<!-- But as the Web became more interactive, logic increasingly determined content. Scripting languages are now in charge of the HTML! This is why **in React, rendering logic and markup live together in the same place—components.** --> + +<!-- TODO: Diagram --> + +<!-- +Keeping a button's rendering logic and markup together ensures that they stay in sync with each other on every edit. Conversely, details that are unrelated, such as the button's markup and a sidebar's markup, are isolated from each other, making it safer to change either of them on their own. + +Each React component is a JavaScript function that may contain some markup that React renders into the browser. React components use a syntax extension called PSX to represent that markup. PSX looks a lot like HTML, but it is a bit stricter and can display dynamic information. The best way to understand this is to convert some HTML markup to PSX markup. + +!!! abstract "Note" + + PSX and ReactPy are two separate things. They're often used together, but you _can_ use them independently of each other. PSX is a syntax extension, while ReactPy is a Python library. + + ## Converting HTML to PSX + +Suppose that you have some (perfectly valid) HTML: + +```html +<h1>Hedy Lamarr's Todos</h1> +<img src="https://i.imgur.com/yXOvdOSs.jpg" alt="Hedy Lamarr" class="photo" /> +<ul> + <li>Invent new traffic lights</li> + <li>Rehearse a movie scene</li> + <li>Improve the spectrum technology</li> +</ul> +``` + +And you want to put it into your component: + +```js +export default function TodoList() { + return ( + // ??? + ) +} +``` + +If you copy and paste it as is, it will not work: + +```js +export default function TodoList() { + return ( + // This doesn't quite work! + <h1>Hedy Lamarr's Todos</h1> + <img + src="https://i.imgur.com/yXOvdOSs.jpg" + alt="Hedy Lamarr" + class="photo" + > + <ul> + <li>Invent new traffic lights + <li>Rehearse a movie scene + <li>Improve the spectrum technology + </ul> + ); +} +``` + +```css +img { + height: 90px; +} +``` + +This is because PSX is stricter and has a few more rules than HTML! If you read the error messages above, they'll guide you to fix the markup, or you can follow the guide below. + +<Note> + +Most of the time, React's on-screen error messages will help you find where the problem is. Give them a read if you get stuck! + +</Note> + +## The Rules of PSX + +### 1. Return a single root element + +To return multiple elements from a component, **wrap them with a single parent tag.** + +For example, you can use a `<div>`: + +```js +<div> + <h1>Hedy Lamarr's Todos</h1> + <img + src="https://i.imgur.com/yXOvdOSs.jpg" + alt="Hedy Lamarr" + class="photo" + > + <ul> + ... + </ul> +</div> +``` + +If you don't want to add an extra `<div>` to your markup, you can write `<>` and `</>` instead: + +```js +<> + <h1>Hedy Lamarr's Todos</h1> + <img + src="https://i.imgur.com/yXOvdOSs.jpg" + alt="Hedy Lamarr" + class="photo" + > + <ul> + ... + </ul> +</> +``` + +This empty tag is called a _[Fragment.](/reference/react/Fragment)_ Fragments let you group things without leaving any trace in the browser HTML tree. + +<DeepDive> + +#### Why do multiple PSX tags need to be wrapped? + +PSX looks like HTML, but under the hood it is transformed into plain JavaScript objects. You can't return two objects from a function without wrapping them into an array. This explains why you also can't return two PSX tags without wrapping them into another tag or a Fragment. + +</DeepDive> + +### 2. Close all the tags + +PSX requires tags to be explicitly closed: self-closing tags like `<img>` must become `<img />`, and wrapping tags like `<li>oranges` must be written as `<li>oranges</li>`. + +This is how Hedy Lamarr's image and list items look closed: + +```js +<> + <img + src="https://i.imgur.com/yXOvdOSs.jpg" + alt="Hedy Lamarr" + class="photo" + /> + <ul> + <li>Invent new traffic lights</li> + <li>Rehearse a movie scene</li> + <li>Improve the spectrum technology</li> + </ul> +</> +``` + +### 3. camelCase <s>all</s> most of the things! + +PSX turns into JavaScript and attributes written in PSX become keys of JavaScript objects. In your own components, you will often want to read those attributes into variables. But JavaScript has limitations on variable names. For example, their names can't contain dashes or be reserved words like `class`. + +This is why, in React, many HTML and SVG attributes are written in camelCase. For example, instead of `stroke-width` you use `strokeWidth`. Since `class` is a reserved word, in React you write `className` instead, named after the [corresponding DOM property](https://developer.mozilla.org/en-US/docs/Web/API/Element/className): + +```js +<img + src="https://i.imgur.com/yXOvdOSs.jpg" + alt="Hedy Lamarr" + className="photo" +/> +``` + +You can [find all these attributes in the list of DOM component props.](/reference/react-dom/components/common) If you get one wrong, don't worry—React will print a message with a possible correction to the [browser console.](https://developer.mozilla.org/docs/Tools/Browser_Console) + +<Pitfall> + +For historical reasons, [`aria-*`](https://developer.mozilla.org/docs/Web/Accessibility/ARIA) and [`data-*`](https://developer.mozilla.org/docs/Learn/HTML/Howto/Use_data_attributes) attributes are written as in HTML with dashes. + +</Pitfall> + +### Pro-tip: Use a PSX Converter + +Converting all these attributes in existing markup can be tedious! We recommend using a [converter](https://transform.tools/html-to-psx) to translate your existing HTML and SVG to PSX. Converters are very useful in practice, but it's still worth understanding what is going on so that you can comfortably write PSX on your own. + +Here is your final result: + +```js +export default function TodoList() { + return ( + <> + <h1>Hedy Lamarr's Todos</h1> + <img + src="https://i.imgur.com/yXOvdOSs.jpg" + alt="Hedy Lamarr" + className="photo" + /> + <ul> + <li>Invent new traffic lights</li> + <li>Rehearse a movie scene</li> + <li>Improve the spectrum technology</li> + </ul> + </> + ); +} +``` + +```css +img { + height: 90px; +} +``` + +<Recap> + +Now you know why PSX exists and how to use it in components: + +- React components group rendering logic together with markup because they are related. +- PSX is similar to HTML, with a few differences. You can use a [converter](https://transform.tools/html-to-psx) if you need to. +- Error messages will often point you in the right direction to fixing your markup. + +</Recap> + +<Challenges> + +#### Convert some HTML to PSX + +This HTML was pasted into a component, but it's not valid PSX. Fix it: + +```js +export default function Bio() { + return ( + <div class="intro"> + <h1>Welcome to my website!</h1> + </div> + <p class="summary"> + You can find my thoughts here. + <br><br> + <b>And <i>pictures</b></i> of scientists! + </p> + ); +} +``` + +```css +.intro { + background-image: linear-gradient( + to left, + violet, + indigo, + blue, + green, + yellow, + orange, + red + ); + background-clip: text; + color: transparent; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.summary { + padding: 20px; + border: 10px solid gold; +} +``` + +Whether to do it by hand or using the converter is up to you! + +<Solution> + +```js +export default function Bio() { + return ( + <div> + <div className="intro"> + <h1>Welcome to my website!</h1> + </div> + <p className="summary"> + You can find my thoughts here. + <br /> + <br /> + <b> + And <i>pictures</i> + </b> of scientists! + </p> + </div> + ); +} +``` + +```css +.intro { + background-image: linear-gradient( + to left, + violet, + indigo, + blue, + green, + yellow, + orange, + red + ); + background-clip: text; + color: transparent; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.summary { + padding: 20px; + border: 10px solid gold; +} +``` + +</Solution> + +</Challenges> --> diff --git a/docs/src/learn/you-might-not-need-an-effect.md b/docs/src/learn/you-might-not-need-an-effect.md new file mode 100644 index 000000000..4652076d8 --- /dev/null +++ b/docs/src/learn/you-might-not-need-an-effect.md @@ -0,0 +1,1698 @@ +## Overview + +<p class="intro" markdown> + +Effects are an escape hatch from the React paradigm. They let you "step outside" of React and synchronize your components with some external system like a non-React widget, network, or the browser DOM. If there is no external system involved (for example, if you want to update a component's state when some props or state change), you shouldn't need an Effect. Removing unnecessary Effects will make your code easier to follow, faster to run, and less error-prone. + +</p> + +!!! summary "You will learn" + + - Why and how to remove unnecessary Effects from your components + - How to cache expensive computations without Effects + - How to reset and adjust component state without Effects + - How to share logic between event handlers + - Which logic should be moved to event handlers + - How to notify parent components about changes + +## How to remove unnecessary Effects + +There are two common cases in which you don't need Effects: + +- **You don't need Effects to transform data for rendering.** For example, let's say you want to filter a list before displaying it. You might feel tempted to write an Effect that updates a state variable when the list changes. However, this is inefficient. When you update the state, React will first call your component functions to calculate what should be on the screen. Then React will ["commit"](/learn/render-and-commit) these changes to the DOM, updating the screen. Then React will run your Effects. If your Effect _also_ immediately updates the state, this restarts the whole process from scratch! To avoid the unnecessary render passes, transform all the data at the top level of your components. That code will automatically re-run whenever your props or state change. +- **You don't need Effects to handle user events.** For example, let's say you want to send an `/api/buy` POST request and show a notification when the user buys a product. In the Buy button click event handler, you know exactly what happened. By the time an Effect runs, you don't know _what_ the user did (for example, which button was clicked). This is why you'll usually handle user events in the corresponding event handlers. + +You _do_ need Effects to [synchronize](/learn/synchronizing-with-effects#what-are-effects-and-how-are-they-different-from-events) with external systems. For example, you can write an Effect that keeps a jQuery widget synchronized with the React state. You can also fetch data with Effects: for example, you can synchronize the search results with the current search query. Keep in mind that modern [frameworks](/learn/start-a-new-react-project#production-grade-react-frameworks) provide more efficient built-in data fetching mechanisms than writing Effects directly in your components. + +To help you gain the right intuition, let's look at some common concrete examples! + +### Updating state based on props or state + +Suppose you have a component with two state variables: `firstName` and `lastName`. You want to calculate a `fullName` from them by concatenating them. Moreover, you'd like `fullName` to update whenever `firstName` or `lastName` change. Your first instinct might be to add a `fullName` state variable and update it in an Effect: + +```js +function Form() { + const [firstName, setFirstName] = useState("Taylor"); + const [lastName, setLastName] = useState("Swift"); + + // 🔴 Avoid: redundant state and unnecessary Effect + const [fullName, setFullName] = useState(""); + useEffect(() => { + setFullName(firstName + " " + lastName); + }, [firstName, lastName]); + // ... +} +``` + +This is more complicated than necessary. It is inefficient too: it does an entire render pass with a stale value for `fullName`, then immediately re-renders with the updated value. Remove the state variable and the Effect: + +```js +function Form() { + const [firstName, setFirstName] = useState("Taylor"); + const [lastName, setLastName] = useState("Swift"); + // ✅ Good: calculated during rendering + const fullName = firstName + " " + lastName; + // ... +} +``` + +**When something can be calculated from the existing props or state, [don't put it in state.](/learn/choosing-the-state-structure#avoid-redundant-state) Instead, calculate it during rendering.** This makes your code faster (you avoid the extra "cascading" updates), simpler (you remove some code), and less error-prone (you avoid bugs caused by different state variables getting out of sync with each other). If this approach feels new to you, [Thinking in React](/learn/thinking-in-react#step-3-find-the-minimal-but-complete-representation-of-ui-state) explains what should go into state. + +### Caching expensive calculations + +This component computes `visibleTodos` by taking the `todos` it receives by props and filtering them according to the `filter` prop. You might feel tempted to store the result in state and update it from an Effect: + +```js +function TodoList({ todos, filter }) { + const [newTodo, setNewTodo] = useState(""); + + // 🔴 Avoid: redundant state and unnecessary Effect + const [visibleTodos, setVisibleTodos] = useState([]); + useEffect(() => { + setVisibleTodos(getFilteredTodos(todos, filter)); + }, [todos, filter]); + + // ... +} +``` + +Like in the earlier example, this is both unnecessary and inefficient. First, remove the state and the Effect: + +```js +function TodoList({ todos, filter }) { + const [newTodo, setNewTodo] = useState(""); + // ✅ This is fine if getFilteredTodos() is not slow. + const visibleTodos = getFilteredTodos(todos, filter); + // ... +} +``` + +Usually, this code is fine! But maybe `getFilteredTodos()` is slow or you have a lot of `todos`. In that case you don't want to recalculate `getFilteredTodos()` if some unrelated state variable like `newTodo` has changed. + +You can cache (or ["memoize"](https://en.wikipedia.org/wiki/Memoization)) an expensive calculation by wrapping it in a [`useMemo`](/reference/react/useMemo) Hook: + +```js +import { useMemo, useState } from "react"; + +function TodoList({ todos, filter }) { + const [newTodo, setNewTodo] = useState(""); + const visibleTodos = useMemo(() => { + // ✅ Does not re-run unless todos or filter change + return getFilteredTodos(todos, filter); + }, [todos, filter]); + // ... +} +``` + +Or, written as a single line: + +```js +import { useMemo, useState } from "react"; + +function TodoList({ todos, filter }) { + const [newTodo, setNewTodo] = useState(""); + // ✅ Does not re-run getFilteredTodos() unless todos or filter change + const visibleTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter] + ); + // ... +} +``` + +**This tells React that you don't want the inner function to re-run unless either `todos` or `filter` have changed.** React will remember the return value of `getFilteredTodos()` during the initial render. During the next renders, it will check if `todos` or `filter` are different. If they're the same as last time, `useMemo` will return the last result it has stored. But if they are different, React will call the inner function again (and store its result). + +The function you wrap in [`useMemo`](/reference/react/useMemo) runs during rendering, so this only works for [pure calculations.](/learn/keeping-components-pure) + +<DeepDive> + +#### How to tell if a calculation is expensive? + +In general, unless you're creating or looping over thousands of objects, it's probably not expensive. If you want to get more confidence, you can add a console log to measure the time spent in a piece of code: + +```js +console.time("filter array"); +const visibleTodos = getFilteredTodos(todos, filter); +console.timeEnd("filter array"); +``` + +Perform the interaction you're measuring (for example, typing into the input). You will then see logs like `filter array: 0.15ms` in your console. If the overall logged time adds up to a significant amount (say, `1ms` or more), it might make sense to memoize that calculation. As an experiment, you can then wrap the calculation in `useMemo` to verify whether the total logged time has decreased for that interaction or not: + +```js +console.time("filter array"); +const visibleTodos = useMemo(() => { + return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed +}, [todos, filter]); +console.timeEnd("filter array"); +``` + +`useMemo` won't make the _first_ render faster. It only helps you skip unnecessary work on updates. + +Keep in mind that your machine is probably faster than your users' so it's a good idea to test the performance with an artificial slowdown. For example, Chrome offers a [CPU Throttling](https://developer.chrome.com/blog/new-in-devtools-61/#throttling) option for this. + +Also note that measuring performance in development will not give you the most accurate results. (For example, when [Strict Mode](/reference/react/StrictMode) is on, you will see each component render twice rather than once.) To get the most accurate timings, build your app for production and test it on a device like your users have. + +</DeepDive> + +### Resetting all state when a prop changes + +This `ProfilePage` component receives a `userId` prop. The page contains a comment input, and you use a `comment` state variable to hold its value. One day, you notice a problem: when you navigate from one profile to another, the `comment` state does not get reset. As a result, it's easy to accidentally post a comment on a wrong user's profile. To fix the issue, you want to clear out the `comment` state variable whenever the `userId` changes: + +```js +export default function ProfilePage({ userId }) { + const [comment, setComment] = useState(""); + + // 🔴 Avoid: Resetting state on prop change in an Effect + useEffect(() => { + setComment(""); + }, [userId]); + // ... +} +``` + +This is inefficient because `ProfilePage` and its children will first render with the stale value, and then render again. It is also complicated because you'd need to do this in _every_ component that has some state inside `ProfilePage`. For example, if the comment UI is nested, you'd want to clear out nested comment state too. + +Instead, you can tell React that each user's profile is conceptually a _different_ profile by giving it an explicit key. Split your component in two and pass a `key` attribute from the outer component to the inner one: + +```js +export default function ProfilePage({ userId }) { + return <Profile userId={userId} key={userId} />; +} + +function Profile({ userId }) { + // ✅ This and any other state below will reset on key change automatically + const [comment, setComment] = useState(""); + // ... +} +``` + +Normally, React preserves the state when the same component is rendered in the same spot. **By passing `userId` as a `key` to the `Profile` component, you're asking React to treat two `Profile` components with different `userId` as two different components that should not share any state.** Whenever the key (which you've set to `userId`) changes, React will recreate the DOM and [reset the state](/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key) of the `Profile` component and all of its children. Now the `comment` field will clear out automatically when navigating between profiles. + +Note that in this example, only the outer `ProfilePage` component is exported and visible to other files in the project. Components rendering `ProfilePage` don't need to pass the key to it: they pass `userId` as a regular prop. The fact `ProfilePage` passes it as a `key` to the inner `Profile` component is an implementation detail. + +### Adjusting some state when a prop changes + +Sometimes, you might want to reset or adjust a part of the state on a prop change, but not all of it. + +This `List` component receives a list of `items` as a prop, and maintains the selected item in the `selection` state variable. You want to reset the `selection` to `null` whenever the `items` prop receives a different array: + +```js +function List({ items }) { + const [isReverse, setIsReverse] = useState(false); + const [selection, setSelection] = useState(null); + + // 🔴 Avoid: Adjusting state on prop change in an Effect + useEffect(() => { + setSelection(null); + }, [items]); + // ... +} +``` + +This, too, is not ideal. Every time the `items` change, the `List` and its child components will render with a stale `selection` value at first. Then React will update the DOM and run the Effects. Finally, the `setSelection(null)` call will cause another re-render of the `List` and its child components, restarting this whole process again. + +Start by deleting the Effect. Instead, adjust the state directly during rendering: + +```js +function List({ items }) { + const [isReverse, setIsReverse] = useState(false); + const [selection, setSelection] = useState(null); + + // Better: Adjust the state while rendering + const [prevItems, setPrevItems] = useState(items); + if (items !== prevItems) { + setPrevItems(items); + setSelection(null); + } + // ... +} +``` + +[Storing information from previous renders](/reference/react/useState#storing-information-from-previous-renders) like this can be hard to understand, but it’s better than updating the same state in an Effect. In the above example, `setSelection` is called directly during a render. React will re-render the `List` _immediately_ after it exits with a `return` statement. React has not rendered the `List` children or updated the DOM yet, so this lets the `List` children skip rendering the stale `selection` value. + +When you update a component during rendering, React throws away the returned JSX and immediately retries rendering. To avoid very slow cascading retries, React only lets you update the _same_ component's state during a render. If you update another component's state during a render, you'll see an error. A condition like `items !== prevItems` is necessary to avoid loops. You may adjust state like this, but any other side effects (like changing the DOM or setting timeouts) should stay in event handlers or Effects to [keep components pure.](/learn/keeping-components-pure) + +**Although this pattern is more efficient than an Effect, most components shouldn't need it either.** No matter how you do it, adjusting state based on props or other state makes your data flow more difficult to understand and debug. Always check whether you can [reset all state with a key](#resetting-all-state-when-a-prop-changes) or [calculate everything during rendering](#updating-state-based-on-props-or-state) instead. For example, instead of storing (and resetting) the selected _item_, you can store the selected _item ID:_ + +```js +function List({ items }) { + const [isReverse, setIsReverse] = useState(false); + const [selectedId, setSelectedId] = useState(null); + // ✅ Best: Calculate everything during rendering + const selection = items.find((item) => item.id === selectedId) ?? null; + // ... +} +``` + +Now there is no need to "adjust" the state at all. If the item with the selected ID is in the list, it remains selected. If it's not, the `selection` calculated during rendering will be `null` because no matching item was found. This behavior is different, but arguably better because most changes to `items` preserve the selection. + +### Sharing logic between event handlers + +Let's say you have a product page with two buttons (Buy and Checkout) that both let you buy that product. You want to show a notification whenever the user puts the product in the cart. Calling `showNotification()` in both buttons' click handlers feels repetitive so you might be tempted to place this logic in an Effect: + +```js +function ProductPage({ product, addToCart }) { + // 🔴 Avoid: Event-specific logic inside an Effect + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name} to the shopping cart!`); + } + }, [product]); + + function handleBuyClick() { + addToCart(product); + } + + function handleCheckoutClick() { + addToCart(product); + navigateTo("/checkout"); + } + // ... +} +``` + +This Effect is unnecessary. It will also most likely cause bugs. For example, let's say that your app "remembers" the shopping cart between the page reloads. If you add a product to the cart once and refresh the page, the notification will appear again. It will keep appearing every time you refresh that product's page. This is because `product.isInCart` will already be `true` on the page load, so the Effect above will call `showNotification()`. + +**When you're not sure whether some code should be in an Effect or in an event handler, ask yourself _why_ this code needs to run. Use Effects only for code that should run _because_ the component was displayed to the user.** In this example, the notification should appear because the user _pressed the button_, not because the page was displayed! Delete the Effect and put the shared logic into a function called from both event handlers: + +```js +function ProductPage({ product, addToCart }) { + // ✅ Good: Event-specific logic is called from event handlers + function buyProduct() { + addToCart(product); + showNotification(`Added ${product.name} to the shopping cart!`); + } + + function handleBuyClick() { + buyProduct(); + } + + function handleCheckoutClick() { + buyProduct(); + navigateTo("/checkout"); + } + // ... +} +``` + +This both removes the unnecessary Effect and fixes the bug. + +### Sending a POST request + +This `Form` component sends two kinds of POST requests. It sends an analytics event when it mounts. When you fill in the form and click the Submit button, it will send a POST request to the `/api/register` endpoint: + +```js +function Form() { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + + // ✅ Good: This logic should run because the component was displayed + useEffect(() => { + post("/analytics/event", { eventName: "visit_form" }); + }, []); + + // 🔴 Avoid: Event-specific logic inside an Effect + const [jsonToSubmit, setJsonToSubmit] = useState(null); + useEffect(() => { + if (jsonToSubmit !== null) { + post("/api/register", jsonToSubmit); + } + }, [jsonToSubmit]); + + function handleSubmit(e) { + e.preventDefault(); + setJsonToSubmit({ firstName, lastName }); + } + // ... +} +``` + +Let's apply the same criteria as in the example before. + +The analytics POST request should remain in an Effect. This is because the _reason_ to send the analytics event is that the form was displayed. (It would fire twice in development, but [see here](/learn/synchronizing-with-effects#sending-analytics) for how to deal with that.) + +However, the `/api/register` POST request is not caused by the form being _displayed_. You only want to send the request at one specific moment in time: when the user presses the button. It should only ever happen _on that particular interaction_. Delete the second Effect and move that POST request into the event handler: + +```js +function Form() { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + + // ✅ Good: This logic runs because the component was displayed + useEffect(() => { + post("/analytics/event", { eventName: "visit_form" }); + }, []); + + function handleSubmit(e) { + e.preventDefault(); + // ✅ Good: Event-specific logic is in the event handler + post("/api/register", { firstName, lastName }); + } + // ... +} +``` + +When you choose whether to put some logic into an event handler or an Effect, the main question you need to answer is _what kind of logic_ it is from the user's perspective. If this logic is caused by a particular interaction, keep it in the event handler. If it's caused by the user _seeing_ the component on the screen, keep it in the Effect. + +### Chains of computations + +Sometimes you might feel tempted to chain Effects that each adjust a piece of state based on other state: + +```js +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + const [isGameOver, setIsGameOver] = useState(false); + + // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other + useEffect(() => { + if (card !== null && card.gold) { + setGoldCardCount(c => c + 1); + } + }, [card]); + + useEffect(() => { + if (goldCardCount > 3) { + setRound(r => r + 1) + setGoldCardCount(0); + } + }, [goldCardCount]); + + useEffect(() => { + if (round > 5) { + setIsGameOver(true); + } + }, [round]); + + useEffect(() => { + alert('Good game!'); + }, [isGameOver]); + + function handlePlaceCard(nextCard) { + if (isGameOver) { + throw Error('Game already ended.'); + } else { + setCard(nextCard); + } + } + + // ... +``` + +There are two problems with this code. + +One problem is that it is very inefficient: the component (and its children) have to re-render between each `set` call in the chain. In the example above, in the worst case (`setCard` → render → `setGoldCardCount` → render → `setRound` → render → `setIsGameOver` → render) there are three unnecessary re-renders of the tree below. + +Even if it weren't slow, as your code evolves, you will run into cases where the "chain" you wrote doesn't fit the new requirements. Imagine you are adding a way to step through the history of the game moves. You'd do it by updating each state variable to a value from the past. However, setting the `card` state to a value from the past would trigger the Effect chain again and change the data you're showing. Such code is often rigid and fragile. + +In this case, it's better to calculate what you can during rendering, and adjust the state in the event handler: + +```js +function Game() { + const [card, setCard] = useState(null); + const [goldCardCount, setGoldCardCount] = useState(0); + const [round, setRound] = useState(1); + + // ✅ Calculate what you can during rendering + const isGameOver = round > 5; + + function handlePlaceCard(nextCard) { + if (isGameOver) { + throw Error('Game already ended.'); + } + + // ✅ Calculate all the next state in the event handler + setCard(nextCard); + if (nextCard.gold) { + if (goldCardCount <= 3) { + setGoldCardCount(goldCardCount + 1); + } else { + setGoldCardCount(0); + setRound(round + 1); + if (round === 5) { + alert('Good game!'); + } + } + } + } + + // ... +``` + +This is a lot more efficient. Also, if you implement a way to view game history, now you will be able to set each state variable to a move from the past without triggering the Effect chain that adjusts every other value. If you need to reuse logic between several event handlers, you can [extract a function](#sharing-logic-between-event-handlers) and call it from those handlers. + +Remember that inside event handlers, [state behaves like a snapshot.](/learn/state-as-a-snapshot) For example, even after you call `setRound(round + 1)`, the `round` variable will reflect the value at the time the user clicked the button. If you need to use the next value for calculations, define it manually like `const nextRound = round + 1`. + +In some cases, you _can't_ calculate the next state directly in the event handler. For example, imagine a form with multiple dropdowns where the options of the next dropdown depend on the selected value of the previous dropdown. Then, a chain of Effects is appropriate because you are synchronizing with network. + +### Initializing the application + +Some logic should only run once when the app loads. + +You might be tempted to place it in an Effect in the top-level component: + +```js +function App() { + // 🔴 Avoid: Effects with logic that should only ever run once + useEffect(() => { + loadDataFromLocalStorage(); + checkAuthToken(); + }, []); + // ... +} +``` + +However, you'll quickly discover that it [runs twice in development.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) This can cause issues--for example, maybe it invalidates the authentication token because the function wasn't designed to be called twice. In general, your components should be resilient to being remounted. This includes your top-level `App` component. + +Although it may not ever get remounted in practice in production, following the same constraints in all components makes it easier to move and reuse code. If some logic must run _once per app load_ rather than _once per component mount_, add a top-level variable to track whether it has already executed: + +```js +let didInit = false; + +function App() { + useEffect(() => { + if (!didInit) { + didInit = true; + // ✅ Only runs once per app load + loadDataFromLocalStorage(); + checkAuthToken(); + } + }, []); + // ... +} +``` + +You can also run it during module initialization and before the app renders: + +```js +if (typeof window !== "undefined") { + // Check if we're running in the browser. + // ✅ Only runs once per app load + checkAuthToken(); + loadDataFromLocalStorage(); +} + +function App() { + // ... +} +``` + +Code at the top level runs once when your component is imported--even if it doesn't end up being rendered. To avoid slowdown or surprising behavior when importing arbitrary components, don't overuse this pattern. Keep app-wide initialization logic to root component modules like `App.js` or in your application's entry point. + +### Notifying parent components about state changes + +Let's say you're writing a `Toggle` component with an internal `isOn` state which can be either `true` or `false`. There are a few different ways to toggle it (by clicking or dragging). You want to notify the parent component whenever the `Toggle` internal state changes, so you expose an `onChange` event and call it from an Effect: + +```js +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + // 🔴 Avoid: The onChange handler runs too late + useEffect(() => { + onChange(isOn); + }, [isOn, onChange]); + + function handleClick() { + setIsOn(!isOn); + } + + function handleDragEnd(e) { + if (isCloserToRightEdge(e)) { + setIsOn(true); + } else { + setIsOn(false); + } + } + + // ... +} +``` + +Like earlier, this is not ideal. The `Toggle` updates its state first, and React updates the screen. Then React runs the Effect, which calls the `onChange` function passed from a parent component. Now the parent component will update its own state, starting another render pass. It would be better to do everything in a single pass. + +Delete the Effect and instead update the state of _both_ components within the same event handler: + +```js +function Toggle({ onChange }) { + const [isOn, setIsOn] = useState(false); + + function updateToggle(nextIsOn) { + // ✅ Good: Perform all updates during the event that caused them + setIsOn(nextIsOn); + onChange(nextIsOn); + } + + function handleClick() { + updateToggle(!isOn); + } + + function handleDragEnd(e) { + if (isCloserToRightEdge(e)) { + updateToggle(true); + } else { + updateToggle(false); + } + } + + // ... +} +``` + +With this approach, both the `Toggle` component and its parent component update their state during the event. React [batches updates](/learn/queueing-a-series-of-state-updates) from different components together, so there will only be one render pass. + +You might also be able to remove the state altogether, and instead receive `isOn` from the parent component: + +```js +// ✅ Also good: the component is fully controlled by its parent +function Toggle({ isOn, onChange }) { + function handleClick() { + onChange(!isOn); + } + + function handleDragEnd(e) { + if (isCloserToRightEdge(e)) { + onChange(true); + } else { + onChange(false); + } + } + + // ... +} +``` + +["Lifting state up"](/learn/sharing-state-between-components) lets the parent component fully control the `Toggle` by toggling the parent's own state. This means the parent component will have to contain more logic, but there will be less state overall to worry about. Whenever you try to keep two different state variables synchronized, try lifting state up instead! + +### Passing data to the parent + +This `Child` component fetches some data and then passes it to the `Parent` component in an Effect: + +```js +function Parent() { + const [data, setData] = useState(null); + // ... + return <Child onFetched={setData} />; +} + +function Child({ onFetched }) { + const data = useSomeAPI(); + // 🔴 Avoid: Passing data to the parent in an Effect + useEffect(() => { + if (data) { + onFetched(data); + } + }, [onFetched, data]); + // ... +} +``` + +In React, data flows from the parent components to their children. When you see something wrong on the screen, you can trace where the information comes from by going up the component chain until you find which component passes the wrong prop or has the wrong state. When child components update the state of their parent components in Effects, the data flow becomes very difficult to trace. Since both the child and the parent need the same data, let the parent component fetch that data, and _pass it down_ to the child instead: + +```js +function Parent() { + const data = useSomeAPI(); + // ... + // ✅ Good: Passing data down to the child + return <Child data={data} />; +} + +function Child({ data }) { + // ... +} +``` + +This is simpler and keeps the data flow predictable: the data flows down from the parent to the child. + +### Subscribing to an external store + +Sometimes, your components may need to subscribe to some data outside of the React state. This data could be from a third-party library or a built-in browser API. Since this data can change without React's knowledge, you need to manually subscribe your components to it. This is often done with an Effect, for example: + +```js +function useOnlineStatus() { + // Not ideal: Manual store subscription in an Effect + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function updateState() { + setIsOnline(navigator.onLine); + } + + updateState(); + + window.addEventListener("online", updateState); + window.addEventListener("offline", updateState); + return () => { + window.removeEventListener("online", updateState); + window.removeEventListener("offline", updateState); + }; + }, []); + return isOnline; +} + +function ChatIndicator() { + const isOnline = useOnlineStatus(); + // ... +} +``` + +Here, the component subscribes to an external data store (in this case, the browser `navigator.onLine` API). Since this API does not exist on the server (so it can't be used for the initial HTML), initially the state is set to `true`. Whenever the value of that data store changes in the browser, the component updates its state. + +Although it's common to use Effects for this, React has a purpose-built Hook for subscribing to an external store that is preferred instead. Delete the Effect and replace it with a call to [`useSyncExternalStore`](/reference/react/useSyncExternalStore): + +```js +function subscribe(callback) { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} + +function useOnlineStatus() { + // ✅ Good: Subscribing to an external store with a built-in Hook + return useSyncExternalStore( + subscribe, // React won't resubscribe for as long as you pass the same function + () => navigator.onLine, // How to get the value on the client + () => true // How to get the value on the server + ); +} + +function ChatIndicator() { + const isOnline = useOnlineStatus(); + // ... +} +``` + +This approach is less error-prone than manually syncing mutable data to React state with an Effect. Typically, you'll write a custom Hook like `useOnlineStatus()` above so that you don't need to repeat this code in the individual components. [Read more about subscribing to external stores from React components.](/reference/react/useSyncExternalStore) + +### Fetching data + +Many apps use Effects to kick off data fetching. It is quite common to write a data fetching Effect like this: + +```js +function SearchResults({ query }) { + const [results, setResults] = useState([]); + const [page, setPage] = useState(1); + + useEffect(() => { + // 🔴 Avoid: Fetching without cleanup logic + fetchResults(query, page).then((json) => { + setResults(json); + }); + }, [query, page]); + + function handleNextPageClick() { + setPage(page + 1); + } + // ... +} +``` + +You _don't_ need to move this fetch to an event handler. + +This might seem like a contradiction with the earlier examples where you needed to put the logic into the event handlers! However, consider that it's not _the typing event_ that's the main reason to fetch. Search inputs are often prepopulated from the URL, and the user might navigate Back and Forward without touching the input. + +It doesn't matter where `page` and `query` come from. While this component is visible, you want to keep `results` [synchronized](/learn/synchronizing-with-effects) with data from the network for the current `page` and `query`. This is why it's an Effect. + +However, the code above has a bug. Imagine you type `"hello"` fast. Then the `query` will change from `"h"`, to `"he"`, `"hel"`, `"hell"`, and `"hello"`. This will kick off separate fetches, but there is no guarantee about which order the responses will arrive in. For example, the `"hell"` response may arrive _after_ the `"hello"` response. Since it will call `setResults()` last, you will be displaying the wrong search results. This is called a ["race condition"](https://en.wikipedia.org/wiki/Race_condition): two different requests "raced" against each other and came in a different order than you expected. + +**To fix the race condition, you need to [add a cleanup function](/learn/synchronizing-with-effects#fetching-data) to ignore stale responses:** + +```js +function SearchResults({ query }) { + const [results, setResults] = useState([]); + const [page, setPage] = useState(1); + useEffect(() => { + let ignore = false; + fetchResults(query, page).then((json) => { + if (!ignore) { + setResults(json); + } + }); + return () => { + ignore = true; + }; + }, [query, page]); + + function handleNextPageClick() { + setPage(page + 1); + } + // ... +} +``` + +This ensures that when your Effect fetches data, all responses except the last requested one will be ignored. + +Handling race conditions is not the only difficulty with implementing data fetching. You might also want to think about caching responses (so that the user can click Back and see the previous screen instantly), how to fetch data on the server (so that the initial server-rendered HTML contains the fetched content instead of a spinner), and how to avoid network waterfalls (so that a child can fetch data without waiting for every parent). + +**These issues apply to any UI library, not just React. Solving them is not trivial, which is why modern [frameworks](/learn/start-a-new-react-project#production-grade-react-frameworks) provide more efficient built-in data fetching mechanisms than fetching data in Effects.** + +If you don't use a framework (and don't want to build your own) but would like to make data fetching from Effects more ergonomic, consider extracting your fetching logic into a custom Hook like in this example: + +```js +function SearchResults({ query }) { + const [page, setPage] = useState(1); + const params = new URLSearchParams({ query, page }); + const results = useData(`/api/search?${params}`); + + function handleNextPageClick() { + setPage(page + 1); + } + // ... +} + +function useData(url) { + const [data, setData] = useState(null); + useEffect(() => { + let ignore = false; + fetch(url) + .then((response) => response.json()) + .then((json) => { + if (!ignore) { + setData(json); + } + }); + return () => { + ignore = true; + }; + }, [url]); + return data; +} +``` + +You'll likely also want to add some logic for error handling and to track whether the content is loading. You can build a Hook like this yourself or use one of the many solutions already available in the React ecosystem. **Although this alone won't be as efficient as using a framework's built-in data fetching mechanism, moving the data fetching logic into a custom Hook will make it easier to adopt an efficient data fetching strategy later.** + +In general, whenever you have to resort to writing Effects, keep an eye out for when you can extract a piece of functionality into a custom Hook with a more declarative and purpose-built API like `useData` above. The fewer raw `useEffect` calls you have in your components, the easier you will find to maintain your application. + +<Recap> + +- If you can calculate something during render, you don't need an Effect. +- To cache expensive calculations, add `useMemo` instead of `useEffect`. +- To reset the state of an entire component tree, pass a different `key` to it. +- To reset a particular bit of state in response to a prop change, set it during rendering. +- Code that runs because a component was _displayed_ should be in Effects, the rest should be in events. +- If you need to update the state of several components, it's better to do it during a single event. +- Whenever you try to synchronize state variables in different components, consider lifting state up. +- You can fetch data with Effects, but you need to implement cleanup to avoid race conditions. + +</Recap> + +<Challenges> + +#### Transform data without Effects + +The `TodoList` below displays a list of todos. When the "Show only active todos" checkbox is ticked, completed todos are not displayed in the list. Regardless of which todos are visible, the footer displays the count of todos that are not yet completed. + +Simplify this component by removing all the unnecessary state and Effects. + +```js +import { useState, useEffect } from "react"; +import { initialTodos, createTodo } from "./todos.js"; + +export default function TodoList() { + const [todos, setTodos] = useState(initialTodos); + const [showActive, setShowActive] = useState(false); + const [activeTodos, setActiveTodos] = useState([]); + const [visibleTodos, setVisibleTodos] = useState([]); + const [footer, setFooter] = useState(null); + + useEffect(() => { + setActiveTodos(todos.filter((todo) => !todo.completed)); + }, [todos]); + + useEffect(() => { + setVisibleTodos(showActive ? activeTodos : todos); + }, [showActive, todos, activeTodos]); + + useEffect(() => { + setFooter(<footer>{activeTodos.length} todos left</footer>); + }, [activeTodos]); + + return ( + <> + <label> + <input + type="checkbox" + checked={showActive} + onChange={(e) => setShowActive(e.target.checked)} + /> + Show only active todos + </label> + <NewTodo onAdd={(newTodo) => setTodos([...todos, newTodo])} /> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + {footer} + </> + ); +} + +function NewTodo({ onAdd }) { + const [text, setText] = useState(""); + + function handleAddClick() { + setText(""); + onAdd(createTodo(text)); + } + + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={handleAddClick}>Add</button> + </> + ); +} +``` + +```js +let nextId = 0; + +export function createTodo(text, completed = false) { + return { + id: nextId++, + text, + completed, + }; +} + +export const initialTodos = [ + createTodo("Get apples", true), + createTodo("Get oranges", true), + createTodo("Get carrots"), +]; +``` + +```css +label { + display: block; +} +input { + margin-top: 10px; +} +``` + +<Hint> + +If you can calculate something during rendering, you don't need state or an Effect that updates it. + +</Hint> + +<Solution> + +There are only two essential pieces of state in this example: the list of `todos` and the `showActive` state variable which represents whether the checkbox is ticked. All of the other state variables are [redundant](/learn/choosing-the-state-structure#avoid-redundant-state) and can be calculated during rendering instead. This includes the `footer` which you can move directly into the surrounding JSX. + +Your result should end up looking like this: + +```js +import { useState } from "react"; +import { initialTodos, createTodo } from "./todos.js"; + +export default function TodoList() { + const [todos, setTodos] = useState(initialTodos); + const [showActive, setShowActive] = useState(false); + const activeTodos = todos.filter((todo) => !todo.completed); + const visibleTodos = showActive ? activeTodos : todos; + + return ( + <> + <label> + <input + type="checkbox" + checked={showActive} + onChange={(e) => setShowActive(e.target.checked)} + /> + Show only active todos + </label> + <NewTodo onAdd={(newTodo) => setTodos([...todos, newTodo])} /> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + <footer>{activeTodos.length} todos left</footer> + </> + ); +} + +function NewTodo({ onAdd }) { + const [text, setText] = useState(""); + + function handleAddClick() { + setText(""); + onAdd(createTodo(text)); + } + + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={handleAddClick}>Add</button> + </> + ); +} +``` + +```js +let nextId = 0; + +export function createTodo(text, completed = false) { + return { + id: nextId++, + text, + completed, + }; +} + +export const initialTodos = [ + createTodo("Get apples", true), + createTodo("Get oranges", true), + createTodo("Get carrots"), +]; +``` + +```css +label { + display: block; +} +input { + margin-top: 10px; +} +``` + +</Solution> + +#### Cache a calculation without Effects + +In this example, filtering the todos was extracted into a separate function called `getVisibleTodos()`. This function contains a `console.log()` call inside of it which helps you notice when it's being called. Toggle "Show only active todos" and notice that it causes `getVisibleTodos()` to re-run. This is expected because visible todos change when you toggle which ones to display. + +Your task is to remove the Effect that recomputes the `visibleTodos` list in the `TodoList` component. However, you need to make sure that `getVisibleTodos()` does _not_ re-run (and so does not print any logs) when you type into the input. + +<Hint> + +One solution is to add a `useMemo` call to cache the visible todos. There is also another, less obvious solution. + +</Hint> + +```js +import { useState, useEffect } from "react"; +import { initialTodos, createTodo, getVisibleTodos } from "./todos.js"; + +export default function TodoList() { + const [todos, setTodos] = useState(initialTodos); + const [showActive, setShowActive] = useState(false); + const [text, setText] = useState(""); + const [visibleTodos, setVisibleTodos] = useState([]); + + useEffect(() => { + setVisibleTodos(getVisibleTodos(todos, showActive)); + }, [todos, showActive]); + + function handleAddClick() { + setText(""); + setTodos([...todos, createTodo(text)]); + } + + return ( + <> + <label> + <input + type="checkbox" + checked={showActive} + onChange={(e) => setShowActive(e.target.checked)} + /> + Show only active todos + </label> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={handleAddClick}>Add</button> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + </> + ); +} +``` + +```js +let nextId = 0; +let calls = 0; + +export function getVisibleTodos(todos, showActive) { + console.log(`getVisibleTodos() was called ${++calls} times`); + const activeTodos = todos.filter((todo) => !todo.completed); + const visibleTodos = showActive ? activeTodos : todos; + return visibleTodos; +} + +export function createTodo(text, completed = false) { + return { + id: nextId++, + text, + completed, + }; +} + +export const initialTodos = [ + createTodo("Get apples", true), + createTodo("Get oranges", true), + createTodo("Get carrots"), +]; +``` + +```css +label { + display: block; +} +input { + margin-top: 10px; +} +``` + +<Solution> + +Remove the state variable and the Effect, and instead add a `useMemo` call to cache the result of calling `getVisibleTodos()`: + +```js +import { useState, useMemo } from "react"; +import { initialTodos, createTodo, getVisibleTodos } from "./todos.js"; + +export default function TodoList() { + const [todos, setTodos] = useState(initialTodos); + const [showActive, setShowActive] = useState(false); + const [text, setText] = useState(""); + const visibleTodos = useMemo( + () => getVisibleTodos(todos, showActive), + [todos, showActive] + ); + + function handleAddClick() { + setText(""); + setTodos([...todos, createTodo(text)]); + } + + return ( + <> + <label> + <input + type="checkbox" + checked={showActive} + onChange={(e) => setShowActive(e.target.checked)} + /> + Show only active todos + </label> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={handleAddClick}>Add</button> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + </> + ); +} +``` + +```js +let nextId = 0; +let calls = 0; + +export function getVisibleTodos(todos, showActive) { + console.log(`getVisibleTodos() was called ${++calls} times`); + const activeTodos = todos.filter((todo) => !todo.completed); + const visibleTodos = showActive ? activeTodos : todos; + return visibleTodos; +} + +export function createTodo(text, completed = false) { + return { + id: nextId++, + text, + completed, + }; +} + +export const initialTodos = [ + createTodo("Get apples", true), + createTodo("Get oranges", true), + createTodo("Get carrots"), +]; +``` + +```css +label { + display: block; +} +input { + margin-top: 10px; +} +``` + +With this change, `getVisibleTodos()` will be called only if `todos` or `showActive` change. Typing into the input only changes the `text` state variable, so it does not trigger a call to `getVisibleTodos()`. + +There is also another solution which does not need `useMemo`. Since the `text` state variable can't possibly affect the list of todos, you can extract the `NewTodo` form into a separate component, and move the `text` state variable inside of it: + +```js +import { useState, useMemo } from "react"; +import { initialTodos, createTodo, getVisibleTodos } from "./todos.js"; + +export default function TodoList() { + const [todos, setTodos] = useState(initialTodos); + const [showActive, setShowActive] = useState(false); + const visibleTodos = getVisibleTodos(todos, showActive); + + return ( + <> + <label> + <input + type="checkbox" + checked={showActive} + onChange={(e) => setShowActive(e.target.checked)} + /> + Show only active todos + </label> + <NewTodo onAdd={(newTodo) => setTodos([...todos, newTodo])} /> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + </> + ); +} + +function NewTodo({ onAdd }) { + const [text, setText] = useState(""); + + function handleAddClick() { + setText(""); + onAdd(createTodo(text)); + } + + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button on_click={handleAddClick}>Add</button> + </> + ); +} +``` + +```js +let nextId = 0; +let calls = 0; + +export function getVisibleTodos(todos, showActive) { + console.log(`getVisibleTodos() was called ${++calls} times`); + const activeTodos = todos.filter((todo) => !todo.completed); + const visibleTodos = showActive ? activeTodos : todos; + return visibleTodos; +} + +export function createTodo(text, completed = false) { + return { + id: nextId++, + text, + completed, + }; +} + +export const initialTodos = [ + createTodo("Get apples", true), + createTodo("Get oranges", true), + createTodo("Get carrots"), +]; +``` + +```css +label { + display: block; +} +input { + margin-top: 10px; +} +``` + +This approach satisfies the requirements too. When you type into the input, only the `text` state variable updates. Since the `text` state variable is in the child `NewTodo` component, the parent `TodoList` component won't get re-rendered. This is why `getVisibleTodos()` doesn't get called when you type. (It would still be called if the `TodoList` re-renders for another reason.) + +</Solution> + +#### Reset state without Effects + +This `EditContact` component receives a contact object shaped like `{ id, name, email }` as the `savedContact` prop. Try editing the name and email input fields. When you press Save, the contact's button above the form updates to the edited name. When you press Reset, any pending changes in the form are discarded. Play around with this UI to get a feel for it. + +When you select a contact with the buttons at the top, the form resets to reflect that contact's details. This is done with an Effect inside `EditContact.js`. Remove this Effect. Find another way to reset the form when `savedContact.id` changes. + +```js +import { useState } from "react"; +import ContactList from "./ContactList.js"; +import EditContact from "./EditContact.js"; + +export default function ContactManager() { + const [contacts, setContacts] = useState(initialContacts); + const [selectedId, setSelectedId] = useState(0); + const selectedContact = contacts.find((c) => c.id === selectedId); + + function handleSave(updatedData) { + const nextContacts = contacts.map((c) => { + if (c.id === updatedData.id) { + return updatedData; + } else { + return c; + } + }); + setContacts(nextContacts); + } + + return ( + <div> + <ContactList + contacts={contacts} + selectedId={selectedId} + onSelect={(id) => setSelectedId(id)} + /> + <hr /> + <EditContact savedContact={selectedContact} onSave={handleSave} /> + </div> + ); +} + +const initialContacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export default function ContactList({ contacts, selectedId, onSelect }) { + return ( + <section> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + onSelect(contact.id); + }} + > + {contact.id === selectedId ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export default function EditContact({ savedContact, onSave }) { + const [name, setName] = useState(savedContact.name); + const [email, setEmail] = useState(savedContact.email); + + useEffect(() => { + setName(savedContact.name); + setEmail(savedContact.email); + }, [savedContact]); + + return ( + <section> + <label> + Name:{" "} + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </label> + <label> + Email:{" "} + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </label> + <button + on_click={() => { + const updatedData = { + id: savedContact.id, + name: name, + email: email, + }; + onSave(updatedData); + }} + > + Save + </button> + <button + on_click={() => { + setName(savedContact.name); + setEmail(savedContact.email); + }} + > + Reset + </button> + </section> + ); +} +``` + +```css +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li { + display: inline-block; +} +li button { + padding: 10px; +} +label { + display: block; + margin: 10px 0; +} +button { + margin-right: 10px; + margin-bottom: 10px; +} +``` + +<Hint> + +It would be nice if there was a way to tell React that when `savedContact.id` is different, the `EditContact` form is conceptually a _different contact's form_ and should not preserve state. Do you recall any such way? + +</Hint> + +<Solution> + +Split the `EditContact` component in two. Move all the form state into the inner `EditForm` component. Export the outer `EditContact` component, and make it pass `savedContact.id` as the `key` to the inner `EditContact` component. As a result, the inner `EditForm` component resets all of the form state and recreates the DOM whenever you select a different contact. + +```js +import { useState } from "react"; +import ContactList from "./ContactList.js"; +import EditContact from "./EditContact.js"; + +export default function ContactManager() { + const [contacts, setContacts] = useState(initialContacts); + const [selectedId, setSelectedId] = useState(0); + const selectedContact = contacts.find((c) => c.id === selectedId); + + function handleSave(updatedData) { + const nextContacts = contacts.map((c) => { + if (c.id === updatedData.id) { + return updatedData; + } else { + return c; + } + }); + setContacts(nextContacts); + } + + return ( + <div> + <ContactList + contacts={contacts} + selectedId={selectedId} + onSelect={(id) => setSelectedId(id)} + /> + <hr /> + <EditContact savedContact={selectedContact} onSave={handleSave} /> + </div> + ); +} + +const initialContacts = [ + { id: 0, name: "Taylor", email: "taylor@mail.com" }, + { id: 1, name: "Alice", email: "alice@mail.com" }, + { id: 2, name: "Bob", email: "bob@mail.com" }, +]; +``` + +```js +export default function ContactList({ contacts, selectedId, onSelect }) { + return ( + <section> + <ul> + {contacts.map((contact) => ( + <li key={contact.id}> + <button + on_click={() => { + onSelect(contact.id); + }} + > + {contact.id === selectedId ? ( + <b>{contact.name}</b> + ) : ( + contact.name + )} + </button> + </li> + ))} + </ul> + </section> + ); +} +``` + +```js +import { useState } from "react"; + +export default function EditContact(props) { + return <EditForm {...props} key={props.savedContact.id} />; +} + +function EditForm({ savedContact, onSave }) { + const [name, setName] = useState(savedContact.name); + const [email, setEmail] = useState(savedContact.email); + + return ( + <section> + <label> + Name:{" "} + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + /> + </label> + <label> + Email:{" "} + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </label> + <button + on_click={() => { + const updatedData = { + id: savedContact.id, + name: name, + email: email, + }; + onSave(updatedData); + }} + > + Save + </button> + <button + on_click={() => { + setName(savedContact.name); + setEmail(savedContact.email); + }} + > + Reset + </button> + </section> + ); +} +``` + +```css +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} +li { + display: inline-block; +} +li button { + padding: 10px; +} +label { + display: block; + margin: 10px 0; +} +button { + margin-right: 10px; + margin-bottom: 10px; +} +``` + +</Solution> + +#### Submit a form without Effects + +This `Form` component lets you send a message to a friend. When you submit the form, the `showForm` state variable is set to `false`. This triggers an Effect calling `sendMessage(message)`, which sends the message (you can see it in the console). After the message is sent, you see a "Thank you" dialog with an "Open chat" button that lets you get back to the form. + +Your app's users are sending way too many messages. To make chatting a little bit more difficult, you've decided to show the "Thank you" dialog _first_ rather than the form. Change the `showForm` state variable to initialize to `false` instead of `true`. As soon as you make that change, the console will show that an empty message was sent. Something in this logic is wrong! + +What's the root cause of this problem? And how can you fix it? + +<Hint> + +Should the message be sent _because_ the user saw the "Thank you" dialog? Or is it the other way around? + +</Hint> + +```js +import { useState, useEffect } from "react"; + +export default function Form() { + const [showForm, setShowForm] = useState(true); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!showForm) { + sendMessage(message); + } + }, [showForm, message]); + + function handleSubmit(e) { + e.preventDefault(); + setShowForm(false); + } + + if (!showForm) { + return ( + <> + <h1>Thanks for using our services!</h1> + <button + on_click={() => { + setMessage(""); + setShowForm(true); + }} + > + Open chat + </button> + </> + ); + } + + return ( + <form onSubmit={handleSubmit}> + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button type="submit" disabled={message === ""}> + Send + </button> + </form> + ); +} + +function sendMessage(message) { + console.log("Sending message: " + message); +} +``` + +```css +label, +textarea { + margin-bottom: 10px; + display: block; +} +``` + +<Solution> + +The `showForm` state variable determines whether to show the form or the "Thank you" dialog. However, you aren't sending the message because the "Thank you" dialog was _displayed_. You want to send the message because the user has _submitted the form._ Delete the misleading Effect and move the `sendMessage` call inside the `handleSubmit` event handler: + +```js +import { useState, useEffect } from "react"; + +export default function Form() { + const [showForm, setShowForm] = useState(true); + const [message, setMessage] = useState(""); + + function handleSubmit(e) { + e.preventDefault(); + setShowForm(false); + sendMessage(message); + } + + if (!showForm) { + return ( + <> + <h1>Thanks for using our services!</h1> + <button + on_click={() => { + setMessage(""); + setShowForm(true); + }} + > + Open chat + </button> + </> + ); + } + + return ( + <form onSubmit={handleSubmit}> + <textarea + placeholder="Message" + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + <button type="submit" disabled={message === ""}> + Send + </button> + </form> + ); +} + +function sendMessage(message) { + console.log("Sending message: " + message); +} +``` + +```css +label, +textarea { + margin-bottom: 10px; + display: block; +} +``` + +Notice how in this version, only _submitting the form_ (which is an event) causes the message to be sent. It works equally well regardless of whether `showForm` is initially set to `true` or `false`. (Set it to `false` and notice no extra console messages.) + +</Solution> + +</Challenges> diff --git a/docs/src/learn/your-first-component.md b/docs/src/learn/your-first-component.md new file mode 100644 index 000000000..1ad627d16 --- /dev/null +++ b/docs/src/learn/your-first-component.md @@ -0,0 +1,400 @@ +## Overview + +<p class="intro" markdown> + +_Components_ are one of the core concepts of React. They are the foundation upon which you build user interfaces (UI), which makes them the perfect place to start your React journey! + +</p> + +!!! summary "You will learn" + + - What a component is + - What role components play in a React application + - How to write your first React component + +## Components: UI building blocks + +On the Web, HTML lets us create rich structured documents with its built-in set of tags like `<h1>` and `<li>`: + +```html +<article> + <h1>My First Component</h1> + <ol> + <li>Components: UI Building Blocks</li> + <li>Defining a Component</li> + <li>Using a Component</li> + </ol> +</article> +``` + +This markup represents this article `<article>`, its heading `<h1>`, and an (abbreviated) table of contents as an ordered list `<ol>`. Markup like this, combined with CSS for style, and JavaScript for interactivity, lies behind every sidebar, avatar, modal, dropdown—every piece of UI you see on the Web. + +React lets you combine your markup, CSS, and JavaScript into custom "components", **reusable UI elements for your app.** The table of contents code you saw above could be turned into a `<TableOfContents />` component you could render on every page. Under the hood, it still uses the same HTML tags like `<article>`, `<h1>`, etc. + +Just like with HTML tags, you can compose, order and nest components to design whole pages. For example, the documentation page you're reading is made out of React components: + +```js +<PageLayout> + <NavigationHeader> + <SearchBar /> + <Link to="/docs">Docs</Link> + </NavigationHeader> + <Sidebar /> + <PageContent> + <TableOfContents /> + <DocumentationText /> + </PageContent> +</PageLayout> +``` + +As your project grows, you will notice that many of your designs can be composed by reusing components you already wrote, speeding up your development. Our table of contents above could be added to any screen with `<TableOfContents />`! You can even jumpstart your project with the thousands of components shared by the React open source community like [Chakra UI](https://chakra-ui.com/) and [Material UI.](https://material-ui.com/) + +## Defining a component + +Traditionally when creating web pages, web developers marked up their content and then added interaction by sprinkling on some JavaScript. This worked great when interaction was a nice-to-have on the web. Now it is expected for many sites and all apps. React puts interactivity first while still using the same technology: **a React component is a JavaScript function that you can _sprinkle with markup_.** Here's what that looks like (you can edit the example below): + +```js +export default function Profile() { + return ( + <img src="https://i.imgur.com/MK3eW3Am.jpg" alt="Katherine Johnson" /> + ); +} +``` + +```css +img { + height: 200px; +} +``` + +And here's how to build a component: + +### Step 1: Export the component + +The `export default` prefix is a [standard JavaScript syntax](https://developer.mozilla.org/docs/web/javascript/reference/statements/export) (not specific to React). It lets you mark the main function in a file so that you can later import it from other files. (More on importing in [Importing and Exporting Components](/learn/importing-and-exporting-components)!) + +### Step 2: Define the function + +With `function Profile() { }` you define a JavaScript function with the name `Profile`. + +<Pitfall> + +React components are regular JavaScript functions, but **their names must start with a capital letter** or they won't work! + +</Pitfall> + +### Step 3: Add markup + +The component returns an `<img />` tag with `src` and `alt` attributes. `<img />` is written like HTML, but it is actually JavaScript under the hood! This syntax is called [JSX](/learn/writing-markup-with-jsx), and it lets you embed markup inside JavaScript. + +Return statements can be written all on one line, as in this component: + +```js +return <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />; +``` + +But if your markup isn't all on the same line as the `return` keyword, you must wrap it in a pair of parentheses: + +```js +return ( + <div> + <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" /> + </div> +); +``` + +<Pitfall> + +Without parentheses, any code on the lines after `return` [will be ignored](https://stackoverflow.com/questions/2846283/what-are-the-rules-for-javascripts-automatic-semicolon-insertion-asi)! + +</Pitfall> + +## Using a component + +Now that you've defined your `Profile` component, you can nest it inside other components. For example, you can export a `Gallery` component that uses multiple `Profile` components: + +```js +function Profile() { + return ( + <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" /> + ); +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +### What the browser sees + +Notice the difference in casing: + +- `<section>` is lowercase, so React knows we refer to an HTML tag. +- `<Profile />` starts with a capital `P`, so React knows that we want to use our component called `Profile`. + +And `Profile` contains even more HTML: `<img />`. In the end, this is what the browser sees: + +```html +<section> + <h1>Amazing scientists</h1> + <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" /> + <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" /> + <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" /> +</section> +``` + +### Nesting and organizing components + +Components are regular JavaScript functions, so you can keep multiple components in the same file. This is convenient when components are relatively small or tightly related to each other. If this file gets crowded, you can always move `Profile` to a separate file. You will learn how to do this shortly on the [page about imports.](/learn/importing-and-exporting-components) + +Because the `Profile` components are rendered inside `Gallery`—even several times!—we can say that `Gallery` is a **parent component,** rendering each `Profile` as a "child". This is part of the magic of React: you can define a component once, and then use it in as many places and as many times as you like. + +<Pitfall> + +Components can render other components, but **you must never nest their definitions:** + +```js +export default function Gallery() { + // 🔴 Never define a component inside another component! + function Profile() { + // ... + } + // ... +} +``` + +The snippet above is [very slow and causes bugs.](/learn/preserving-and-resetting-state#different-components-at-the-same-position-reset-state) Instead, define every component at the top level: + +```js +export default function Gallery() { + // ... +} + +// ✅ Declare components at the top level +function Profile() { + // ... +} +``` + +When a child component needs some data from a parent, [pass it by props](/learn/passing-props-to-a-component) instead of nesting definitions. + +</Pitfall> + +<DeepDive> + +#### Components all the way down + +Your React application begins at a "root" component. Usually, it is created automatically when you start a new project. For example, if you use [CodeSandbox](https://codesandbox.io/) or [Create React App](https://create-react-app.dev/), the root component is defined in `src/App.js`. If you use the framework [Next.js](https://nextjs.org/), the root component is defined in `pages/index.js`. In these examples, you've been exporting root components. + +Most React apps use components all the way down. This means that you won't only use components for reusable pieces like buttons, but also for larger pieces like sidebars, lists, and ultimately, complete pages! Components are a handy way to organize UI code and markup, even if some of them are only used once. + +[React-based frameworks](/learn/start-a-new-react-project) take this a step further. Instead of using an empty HTML file and letting React "take over" managing the page with JavaScript, they _also_ generate the HTML automatically from your React components. This allows your app to show some content before the JavaScript code loads. + +Still, many websites only use React to [add interactivity to existing HTML pages.](/learn/add-react-to-an-existing-project#using-react-for-a-part-of-your-existing-page) They have many root components instead of a single one for the entire page. You can use as much—or as little—React as you need. + +</DeepDive> + +<Recap> + +You've just gotten your first taste of React! Let's recap some key points. + +- React lets you create components, **reusable UI elements for your app.** +- In a React app, every piece of UI is a component. +- React components are regular JavaScript functions except: + + 1. Their names always begin with a capital letter. + 2. They return JSX markup. + +</Recap> + +<Challenges> + +#### Export the component + +This sandbox doesn't work because the root component is not exported: + +```js +function Profile() { + return <img src="https://i.imgur.com/lICfvbD.jpg" alt="Aklilu Lemma" />; +} +``` + +```css +img { + height: 181px; +} +``` + +Try to fix it yourself before looking at the solution! + +<Solution> + +Add `export default` before the function definition like so: + +```js +export default function Profile() { + return <img src="https://i.imgur.com/lICfvbD.jpg" alt="Aklilu Lemma" />; +} +``` + +```css +img { + height: 181px; +} +``` + +You might be wondering why writing `export` alone is not enough to fix this example. You can learn the difference between `export` and `export default` in [Importing and Exporting Components.](/learn/importing-and-exporting-components) + +</Solution> + +#### Fix the return statement + +Something isn't right about this `return` statement. Can you fix it? + +<Hint> + +You may get an "Unexpected token" error while trying to fix this. In that case, check that the semicolon appears _after_ the closing parenthesis. Leaving a semicolon inside `return ( )` will cause an error. + +</Hint> + +```js +export default function Profile() { + return; + <img src="https://i.imgur.com/jA8hHMpm.jpg" alt="Katsuko Saruhashi" />; +} +``` + +```css +img { + height: 180px; +} +``` + +<Solution> + +You can fix this component by moving the return statement to one line like so: + +```js +export default function Profile() { + return ( + <img src="https://i.imgur.com/jA8hHMpm.jpg" alt="Katsuko Saruhashi" /> + ); +} +``` + +```css +img { + height: 180px; +} +``` + +Or by wrapping the returned JSX markup in parentheses that open right after `return`: + +```js +export default function Profile() { + return ( + <img src="https://i.imgur.com/jA8hHMpm.jpg" alt="Katsuko Saruhashi" /> + ); +} +``` + +```css +img { + height: 180px; +} +``` + +</Solution> + +#### Spot the mistake + +Something's wrong with how the `Profile` component is declared and used. Can you spot the mistake? (Try to remember how React distinguishes components from the regular HTML tags!) + +```js +function profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <profile /> + <profile /> + <profile /> + </section> + ); +} +``` + +```css +img { + margin: 0 10px 10px 0; + height: 90px; +} +``` + +<Solution> + +React component names must start with a capital letter. + +Change `function profile()` to `function Profile()`, and then change every `<profile />` to `<Profile />`: + +```js +function Profile() { + return <img src="https://i.imgur.com/QIrZWGIs.jpg" alt="Alan L. Hart" />; +} + +export default function Gallery() { + return ( + <section> + <h1>Amazing scientists</h1> + <Profile /> + <Profile /> + <Profile /> + </section> + ); +} +``` + +```css +img { + margin: 0 10px 10px 0; +} +``` + +</Solution> + +#### Your own component + +Write a component from scratch. You can give it any valid name and return any markup. If you're out of ideas, you can write a `Congratulations` component that shows `<h1>Good job!</h1>`. Don't forget to export it! + +```js +// Write your component below! +``` + +<Solution> + +```js +export default function Congratulations() { + return <h1>Good job!</h1>; +} +``` + +</Solution> + +</Challenges> diff --git a/docs/src/reference/client-api.md b/docs/src/reference/client-api.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/common-props.md b/docs/src/reference/common-props.md new file mode 100644 index 000000000..41e9048d7 --- /dev/null +++ b/docs/src/reference/common-props.md @@ -0,0 +1,216 @@ +#### Props + +These special React props are supported for all built-in components: + +- `children`: A React node (an element, a string, a number, [a portal,](/reference/react-dom/createPortal) an empty node like `null`, `undefined` and booleans, or an array of other React nodes). Specifies the content inside the component. When you use JSX, you will usually specify the `children` prop implicitly by nesting tags like `<div><span /></div>`. + +- `dangerouslySetInnerHTML`: An object of the form `{ __html: '<p>some html</p>' }` with a raw HTML string inside. Overrides the [`innerHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML) property of the DOM node and displays the passed HTML inside. This should be used with extreme caution! If the HTML inside isn't trusted (for example, if it's based on user data), you risk introducing an [XSS](https://en.wikipedia.org/wiki/Cross-site_scripting) vulnerability. [Read more about using `dangerouslySetInnerHTML`.](#dangerously-setting-the-inner-html) + +- `ref`: A ref object from [`useRef`](/reference/react/useRef) or [`createRef`](/reference/react/createRef), or a [`ref` callback function,](#ref-callback) or a string for [legacy refs.](https://reactjs.org/docs/refs-and-the-dom.html#legacy-api-string-refs) Your ref will be filled with the DOM element for this node. [Read more about manipulating the DOM with refs.](#manipulating-a-dom-node-with-a-ref) + +- `suppressContentEditableWarning`: A boolean. If `true`, suppresses the warning that React shows for elements that both have `children` and `contentEditable={true}` (which normally do not work together). Use this if you're building a text input library that manages the `contentEditable` content manually. + +- `suppressHydrationWarning`: A boolean. If you use [server rendering,](/reference/react-dom/server) normally there is a warning when the server and the client render different content. In some rare cases (like timestamps), it is very hard or impossible to guarantee an exact match. If you set `suppressHydrationWarning` to `true`, React will not warn you about mismatches in the attributes and the content of that element. It only works one level deep, and is intended to be used as an escape hatch. Don't overuse it. [Read about suppressing hydration errors.](/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) + +- `style`: An object with CSS styles, for example `{ fontWeight: 'bold', margin: 20 }`. Similarly to the DOM [`style`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) property, the CSS property names need to be written as `camelCase`, for example `fontWeight` instead of `font-weight`. You can pass strings or numbers as values. If you pass a number, like `width: 100`, React will automatically append `px` ("pixels") to the value unless it's a [unitless property.](https://github.com/facebook/react/blob/81d4ee9ca5c405dce62f64e61506b8e155f38d8d/packages/react-dom-bindings/src/shared/CSSProperty.js#L8-L57) We recommend using `style` only for dynamic styles where you don't know the style values ahead of time. In other cases, applying plain CSS classes with `className` is more efficient. [Read more about `className` and `style`.](#applying-css-styles) + +These standard DOM props are also supported for all built-in components: + +- [`accessKey`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey): A string. Specifies a keyboard shortcut for the element. [Not generally recommended.](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey#accessibility_concerns) +- [`aria-*`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes): ARIA attributes let you specify the accessibility tree information for this element. See [ARIA attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes) for a complete reference. In React, all ARIA attribute names are exactly the same as in HTML. +- [`autoCapitalize`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autocapitalize): A string. Specifies whether and how the user input should be capitalized. +- [`className`](https://developer.mozilla.org/en-US/docs/Web/API/Element/className): A string. Specifies the element's CSS class name. [Read more about applying CSS styles.](#applying-css-styles) +- [`contentEditable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable): A boolean. If `true`, the browser lets the user edit the rendered element directly. This is used to implement rich text input libraries like [Lexical.](https://lexical.dev/) React warns if you try to pass React children to an element with `contentEditable={true}` because React will not be able to update its content after user edits. +- [`data-*`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*): Data attributes let you attach some string data to the element, for example `data-fruit="banana"`. In React, they are not commonly used because you would usually read data from props or state instead. +- [`dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir): Either `'ltr'` or `'rtl'`. Specifies the text direction of the element. +- [`draggable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable): A boolean. Specifies whether the element is draggable. Part of [HTML Drag and Drop API.](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API) +- [`enterKeyHint`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/enterKeyHint): A string. Specifies which action to present for the enter key on virtual keyboards. +- [`htmlFor`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLabelElement/htmlFor): A string. For [`<label>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label) and [`<output>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/output), lets you [associate the label with some control.](/reference/react-dom/components/input#providing-a-label-for-an-input) Same as [`for` HTML attribute.](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/for) React uses the standard DOM property names (`htmlFor`) instead of HTML attribute names. +- [`hidden`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden): A boolean or a string. Specifies whether the element should be hidden. +- [`id`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id): A string. Specifies a unique identifier for this element, which can be used to find it later or connect it with other elements. Generate it with [`useId`](/reference/react/useId) to avoid clashes between multiple instances of the same component. +- [`is`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is): A string. If specified, the component will behave like a [custom element.](/reference/react-dom/components#custom-html-elements) +- [`inputMode`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode): A string. Specifies what kind of keyboard to display (for example, text, number or telephone). +- [`itemProp`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/itemprop): A string. Specifies which property the element represents for structured data crawlers. +- [`lang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang): A string. Specifies the language of the element. +- [`onAnimationEnd`](https://developer.mozilla.org/en-US/docs/Web/API/Element/animationend_event): An [`AnimationEvent` handler](#animationevent-handler) function. Fires when a CSS animation completes. +- `onAnimationEndCapture`: A version of `onAnimationEnd` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onAnimationIteration`](https://developer.mozilla.org/en-US/docs/Web/API/Element/animationiteration_event): An [`AnimationEvent` handler](#animationevent-handler) function. Fires when an iteration of a CSS animation ends, and another one begins. +- `onAnimationIterationCapture`: A version of `onAnimationIteration` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onAnimationStart`](https://developer.mozilla.org/en-US/docs/Web/API/Element/animationstart_event): An [`AnimationEvent` handler](#animationevent-handler) function. Fires when a CSS animation starts. +- `onAnimationStartCapture`: `onAnimationStart`, but fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onAuxClick`](https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when a non-primary pointer button was clicked. +- `onAuxClickCapture`: A version of `onAuxClick` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- `onBeforeInput`: An [`InputEvent` handler](#inputevent-handler) function. Fires before the value of an editable element is modified. React does _not_ yet use the native [`beforeinput`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event) event, and instead attempts to polyfill it using other events. +- `onBeforeInputCapture`: A version of `onBeforeInput` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- `onBlur`: A [`FocusEvent` handler](#focusevent-handler) function. Fires when an element lost focus. Unlike the built-in browser [`blur`](https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event) event, in React the `onBlur` event bubbles. +- `onBlurCapture`: A version of `onBlur` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onClick`](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the primary button was clicked on the pointing device. +- `onClickCapture`: A version of `onClick` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCompositionStart`](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event): A [`CompositionEvent` handler](#compositionevent-handler) function. Fires when an [input method editor](https://developer.mozilla.org/en-US/docs/Glossary/Input_method_editor) starts a new composition session. +- `onCompositionStartCapture`: A version of `onCompositionStart` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCompositionEnd`](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event): A [`CompositionEvent` handler](#compositionevent-handler) function. Fires when an [input method editor](https://developer.mozilla.org/en-US/docs/Glossary/Input_method_editor) completes or cancels a composition session. +- `onCompositionEndCapture`: A version of `onCompositionEnd` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCompositionUpdate`](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event): A [`CompositionEvent` handler](#compositionevent-handler) function. Fires when an [input method editor](https://developer.mozilla.org/en-US/docs/Glossary/Input_method_editor) receives a new character. +- `onCompositionUpdateCapture`: A version of `onCompositionUpdate` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onContextMenu`](https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the user tries to open a context menu. +- `onContextMenuCapture`: A version of `onContextMenu` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCopy`](https://developer.mozilla.org/en-US/docs/Web/API/Element/copy_event): A [`ClipboardEvent` handler](#clipboardevent-handler) function. Fires when the user tries to copy something into the clipboard. +- `onCopyCapture`: A version of `onCopy` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCut`](https://developer.mozilla.org/en-US/docs/Web/API/Element/cut_event): A [`ClipboardEvent` handler](#clipboardevent-handler) function. Fires when the user tries to cut something into the clipboard. +- `onCutCapture`: A version of `onCut` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- `onDoubleClick`: A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the user clicks twice. Corresponds to the browser [`dblclick` event.](https://developer.mozilla.org/en-US/docs/Web/API/Element/dblclick_event) +- `onDoubleClickCapture`: A version of `onDoubleClick` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDrag`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drag_event): A [`DragEvent` handler](#dragevent-handler) function. Fires while the user is dragging something. +- `onDragCapture`: A version of `onDrag` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDragEnd`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragend_event): A [`DragEvent` handler](#dragevent-handler) function. Fires when the user stops dragging something. +- `onDragEndCapture`: A version of `onDragEnd` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDragEnter`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragenter_event): A [`DragEvent` handler](#dragevent-handler) function. Fires when the dragged content enters a valid drop target. +- `onDragEnterCapture`: A version of `onDragEnter` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDragOver`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragover_event): A [`DragEvent` handler](#dragevent-handler) function. Fires on a valid drop target while the dragged content is dragged over it. You must call `e.preventDefault()` here to allow dropping. +- `onDragOverCapture`: A version of `onDragOver` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDragStart`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragstart_event): A [`DragEvent` handler](#dragevent-handler) function. Fires when the user starts dragging an element. +- `onDragStartCapture`: A version of `onDragStart` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDrop`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event): A [`DragEvent` handler](#dragevent-handler) function. Fires when something is dropped on a valid drop target. +- `onDropCapture`: A version of `onDrop` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- `onFocus`: A [`FocusEvent` handler](#focusevent-handler) function. Fires when an element lost focus. Unlike the built-in browser [`focus`](https://developer.mozilla.org/en-US/docs/Web/API/Element/focus_event) event, in React the `onFocus` event bubbles. +- `onFocusCapture`: A version of `onFocus` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onGotPointerCapture`](https://developer.mozilla.org/en-US/docs/Web/API/Element/gotpointercapture_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when an element programmatically captures a pointer. +- `onGotPointerCaptureCapture`: A version of `onGotPointerCapture` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onKeyDown`](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event): A [`KeyboardEvent` handler](#pointerevent-handler) function. Fires when a key is pressed. +- `onKeyDownCapture`: A version of `onKeyDown` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onKeyPress`](https://developer.mozilla.org/en-US/docs/Web/API/Element/keypress_event): A [`KeyboardEvent` handler](#pointerevent-handler) function. Deprecated. Use `onKeyDown` or `onBeforeInput` instead. +- `onKeyPressCapture`: A version of `onKeyPress` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onKeyUp`](https://developer.mozilla.org/en-US/docs/Web/API/Element/keyup_event): A [`KeyboardEvent` handler](#pointerevent-handler) function. Fires when a key is released. +- `onKeyUpCapture`: A version of `onKeyUp` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onLostPointerCapture`](https://developer.mozilla.org/en-US/docs/Web/API/Element/lostpointercapture_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when an element stops capturing a pointer. +- `onLostPointerCaptureCapture`: A version of `onLostPointerCapture` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onMouseDown`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousedown_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the pointer is pressed down. +- `onMouseDownCapture`: A version of `onMouseDown` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onMouseEnter`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseenter_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the pointer moves inside an element. Does not have a capture phase. Instead, `onMouseLeave` and `onMouseEnter` propagate from the element being left to the one being entered. +- [`onMouseLeave`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseleave_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the pointer moves outside an element. Does not have a capture phase. Instead, `onMouseLeave` and `onMouseEnter` propagate from the element being left to the one being entered. +- [`onMouseMove`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousemove_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the pointer changes coordinates. +- `onMouseMoveCapture`: A version of `onMouseMove` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onMouseOut`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseout_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the pointer moves outside an element, or if it moves into a child element. +- `onMouseOutCapture`: A version of `onMouseOut` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onMouseUp`](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseup_event): A [`MouseEvent` handler](#mouseevent-handler) function. Fires when the pointer is released. +- `onMouseUpCapture`: A version of `onMouseUp` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPointerCancel`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointercancel_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when the browser cancels a pointer interaction. +- `onPointerCancelCapture`: A version of `onPointerCancel` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPointerDown`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerdown_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when a pointer becomes active. +- `onPointerDownCapture`: A version of `onPointerDown` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPointerEnter`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerenter_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when a pointer moves inside an element. Does not have a capture phase. Instead, `onPointerLeave` and `onPointerEnter` propagate from the element being left to the one being entered. +- [`onPointerLeave`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerleave_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when a pointer moves outside an element. Does not have a capture phase. Instead, `onPointerLeave` and `onPointerEnter` propagate from the element being left to the one being entered. +- [`onPointerMove`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointermove_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when a pointer changes coordinates. +- `onPointerMoveCapture`: A version of `onPointerMove` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPointerOut`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerout_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when a pointer moves outside an element, if the pointer interaction is cancelled, and [a few other reasons.](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerout_event) +- `onPointerOutCapture`: A version of `onPointerOut` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPointerUp`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerup_event): A [`PointerEvent` handler](#pointerevent-handler) function. Fires when a pointer is no longer active. +- `onPointerUpCapture`: A version of `onPointerUp` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPaste`](https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event): A [`ClipboardEvent` handler](#clipboardevent-handler) function. Fires when the user tries to paste something from the clipboard. +- `onPasteCapture`: A version of `onPaste` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onScroll`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scroll_event): An [`Event` handler](#event-handler) function. Fires when an element has been scrolled. This event does not bubble. +- `onScrollCapture`: A version of `onScroll` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onSelect`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select_event): An [`Event` handler](#event-handler) function. Fires after the selection inside an editable element like an input changes. React extends the `onSelect` event to work for `contentEditable={true}` elements as well. In addition, React extends it to fire for empty selection and on edits (which may affect the selection). +- `onSelectCapture`: A version of `onSelect` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onTouchCancel`](https://developer.mozilla.org/en-US/docs/Web/API/Element/touchcancel_event): A [`TouchEvent` handler](#touchevent-handler) function. Fires when the browser cancels a touch interaction. +- `onTouchCancelCapture`: A version of `onTouchCancel` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onTouchEnd`](https://developer.mozilla.org/en-US/docs/Web/API/Element/touchend_event): A [`TouchEvent` handler](#touchevent-handler) function. Fires when one or more touch points are removed. +- `onTouchEndCapture`: A version of `onTouchEnd` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onTouchMove`](https://developer.mozilla.org/en-US/docs/Web/API/Element/touchmove_event): A [`TouchEvent` handler](#touchevent-handler) function. Fires one or more touch points are moved. +- `onTouchMoveCapture`: A version of `onTouchMove` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onTouchStart`](https://developer.mozilla.org/en-US/docs/Web/API/Element/touchstart_event): A [`TouchEvent` handler](#touchevent-handler) function. Fires when one or more touch points are placed. +- `onTouchStartCapture`: A version of `onTouchStart` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onTransitionEnd`](https://developer.mozilla.org/en-US/docs/Web/API/Element/transitionend_event): A [`TransitionEvent` handler](#transitionevent-handler) function. Fires when a CSS transition completes. +- `onTransitionEndCapture`: A version of `onTransitionEnd` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onWheel`](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event): A [`WheelEvent` handler](#wheelevent-handler) function. Fires when the user rotates a wheel button. +- `onWheelCapture`: A version of `onWheel` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`role`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles): A string. Specifies the element role explicitly for assistive technologies. nt. +- [`slot`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles): A string. Specifies the slot name when using shadow DOM. In React, an equivalent pattern is typically achieved by passing JSX as props, for example `<Layout left={<Sidebar />} right={<Content />} />`. +- [`spellCheck`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck): A boolean or null. If explicitly set to `true` or `false`, enables or disables spellchecking. +- [`tabIndex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex): A number. Overrides the default Tab button behavior. [Avoid using values other than `-1` and `0`.](https://www.tpgi.com/using-the-tabindex-attribute/) +- [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title): A string. Specifies the tooltip text for the element. +- [`translate`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/translate): Either `'yes'` or `'no'`. Passing `'no'` excludes the element content from being translated. + +You can also pass custom attributes as props, for example `mycustomprop="someValue"`. This can be useful when integrating with third-party libraries. The custom attribute name must be lowercase and must not start with `on`. The value will be converted to a string. If you pass `null` or `undefined`, the custom attribute will be removed. + +These events fire only for the [`<form>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) elements: + +- [`onReset`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/reset_event): An [`Event` handler](#event-handler) function. Fires when a form gets reset. +- `onResetCapture`: A version of `onReset` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onSubmit`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event): An [`Event` handler](#event-handler) function. Fires when a form gets submitted. +- `onSubmitCapture`: A version of `onSubmit` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) + +These events fire only for the [`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) elements. Unlike browser events, they bubble in React: + +- [`onCancel`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/cancel_event): An [`Event` handler](#event-handler) function. Fires when the user tries to dismiss the dialog. +- `onCancelCapture`: A version of `onCancel` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) capture-phase-events) +- [`onClose`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event): An [`Event` handler](#event-handler) function. Fires when a dialog has been closed. +- `onCloseCapture`: A version of `onClose` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) + +These events fire only for the [`<details>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) elements. Unlike browser events, they bubble in React: + +- [`onToggle`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement/toggle_event): An [`Event` handler](#event-handler) function. Fires when the user toggles the details. +- `onToggleCapture`: A version of `onToggle` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) capture-phase-events) + +These events fire for [`<img>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img), [`<iframe>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe), [`<object>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/object), [`<embed>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/embed), [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link), and [SVG `<image>`](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/SVG_Image_Tag) elements. Unlike browser events, they bubble in React: + +- `onLoad`: An [`Event` handler](#event-handler) function. Fires when the resource has loaded. +- `onLoadCapture`: A version of `onLoad` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onError`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error_event): An [`Event` handler](#event-handler) function. Fires when the resource could not be loaded. +- `onErrorCapture`: A version of `onError` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) + +These events fire for resources like [`<audio>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio) and [`<video>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video). Unlike browser events, they bubble in React: + +- [`onAbort`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/abort_event): An [`Event` handler](#event-handler) function. Fires when the resource has not fully loaded, but not due to an error. +- `onAbortCapture`: A version of `onAbort` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCanPlay`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplay_event): An [`Event` handler](#event-handler) function. Fires when there's enough data to start playing, but not enough to play to the end without buffering. +- `onCanPlayCapture`: A version of `onCanPlay` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onCanPlayThrough`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplaythrough_event): An [`Event` handler](#event-handler) function. Fires when there's enough data that it's likely possible to start playing without buffering until the end. +- `onCanPlayThroughCapture`: A version of `onCanPlayThrough` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onDurationChange`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/durationchange_event): An [`Event` handler](#event-handler) function. Fires when the media duration has updated. +- `onDurationChangeCapture`: A version of `onDurationChange` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onEmptied`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/emptied_event): An [`Event` handler](#event-handler) function. Fires when the media has become empty. +- `onEmptiedCapture`: A version of `onEmptied` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onEncrypted`](https://w3c.github.io/encrypted-media/#dom-evt-encrypted): An [`Event` handler](#event-handler) function. Fires when the browser encounters encrypted media. +- `onEncryptedCapture`: A version of `onEncrypted` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onEnded`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended_event): An [`Event` handler](#event-handler) function. Fires when the playback stops because there's nothing left to play. +- `onEndedCapture`: A version of `onEnded` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onError`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error_event): An [`Event` handler](#event-handler) function. Fires when the resource could not be loaded. +- `onErrorCapture`: A version of `onError` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onLoadedData`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadeddata_event): An [`Event` handler](#event-handler) function. Fires when the current playback frame has loaded. +- `onLoadedDataCapture`: A version of `onLoadedData` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onLoadedMetadata`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadedmetadata_event): An [`Event` handler](#event-handler) function. Fires when metadata has loaded. +- `onLoadedMetadataCapture`: A version of `onLoadedMetadata` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onLoadStart`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadstart_event): An [`Event` handler](#event-handler) function. Fires when the browser started loading the resource. +- `onLoadStartCapture`: A version of `onLoadStart` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPause`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause_event): An [`Event` handler](#event-handler) function. Fires when the media was paused. +- `onPauseCapture`: A version of `onPause` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPlay`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play_event): An [`Event` handler](#event-handler) function. Fires when the media is no longer paused. +- `onPlayCapture`: A version of `onPlay` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onPlaying`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playing_event): An [`Event` handler](#event-handler) function. Fires when the media starts or restarts playing. +- `onPlayingCapture`: A version of `onPlaying` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onProgress`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/progress_event): An [`Event` handler](#event-handler) function. Fires periodically while the resource is loading. +- `onProgressCapture`: A version of `onProgress` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onRateChange`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ratechange_event): An [`Event` handler](#event-handler) function. Fires when playback rate changes. +- `onRateChangeCapture`: A version of `onRateChange` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- `onResize`: An [`Event` handler](#event-handler) function. Fires when video changes size. +- `onResizeCapture`: A version of `onResize` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onSeeked`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/seeked_event): An [`Event` handler](#event-handler) function. Fires when a seek operation completes. +- `onSeekedCapture`: A version of `onSeeked` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onSeeking`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/seeking_event): An [`Event` handler](#event-handler) function. Fires when a seek operation starts. +- `onSeekingCapture`: A version of `onSeeking` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onStalled`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/stalled_event): An [`Event` handler](#event-handler) function. Fires when the browser is waiting for data but it keeps not loading. +- `onStalledCapture`: A version of `onStalled` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onSuspend`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/suspend_event): An [`Event` handler](#event-handler) function. Fires when loading the resource was suspended. +- `onSuspendCapture`: A version of `onSuspend` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onTimeUpdate`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/timeupdate_event): An [`Event` handler](#event-handler) function. Fires when the current playback time updates. +- `onTimeUpdateCapture`: A version of `onTimeUpdate` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onVolumeChange`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volumechange_event): An [`Event` handler](#event-handler) function. Fires when the volume has changed. +- `onVolumeChangeCapture`: A version of `onVolumeChange` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) +- [`onWaiting`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/waiting_event): An [`Event` handler](#event-handler) function. Fires when the playback stopped due to temporary lack of data. +- `onWaitingCapture`: A version of `onWaiting` that fires in the [capture phase.](/learn/responding-to-events#capture-phase-events) + +#### Caveats + +- You cannot pass both `children` and `dangerouslySetInnerHTML` at the same time. +- Some events (like `onAbort` and `onLoad`) don't bubble in the browser, but bubble in React. diff --git a/docs/src/reference/components-and-hooks-must-be-pure.md b/docs/src/reference/components-and-hooks-must-be-pure.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/overview.md b/docs/src/reference/overview.md new file mode 100644 index 000000000..51d6b24b9 --- /dev/null +++ b/docs/src/reference/overview.md @@ -0,0 +1,38 @@ +<p class="intro" markdown> + +This section provides detailed reference documentation for working with React. For an introduction to React, please visit the [Learn](/learn) section. + +</p> + +The React reference documentation is broken down into functional subsections: + +## React + +Programmatic React features: + +- [Hooks](/reference/react/hooks) - Use different React features from your components. +- [Components](/reference/react/components) - Built-in components that you can use in your JSX. +- [APIs](/reference/react/apis) - APIs that are useful for defining components. +- [Directives](/reference/rsc/directives) - Provide instructions to bundlers compatible with React Server Components. + +## React DOM + +React-dom contains features that are only supported for web applications (which run in the browser DOM environment). This section is broken into the following: + +- [Hooks](/reference/react-dom/hooks) - Hooks for web applications which run in the browser DOM environment. +- [Components](/reference/react-dom/components) - React supports all of the browser built-in HTML and SVG components. +- [APIs](/reference/react-dom) - The `react-dom` package contains methods supported only in web applications. +- [Client APIs](/reference/react-dom/client) - The `react-dom/client` APIs let you render React components on the client (in the browser). +- [Server APIs](/reference/react-dom/server) - The `react-dom/server` APIs let you render React components to HTML on the server. + +## Rules of React + +React has idioms — or rules — for how to express patterns in a way that is easy to understand and yields high-quality applications: + +- [Components and Hooks must be pure](/reference/rules/components-and-hooks-must-be-pure) – Purity makes your code easier to understand, debug, and allows React to automatically optimize your components and hooks correctly. +- [React calls Components and Hooks](/reference/rules/react-calls-components-and-hooks) – React is responsible for rendering components and hooks when necessary to optimize the user experience. +- [Rules of Hooks](/reference/rules/rules-of-hooks) – Hooks are defined using JavaScript functions, but they represent a special type of reusable UI logic with restrictions on where they can be called. + +## Legacy APIs + +- [Legacy APIs](/reference/react/legacy) - Exported from the `react` package, but not recommended for use in newly written code. diff --git a/docs/src/reference/protocol-structure.md b/docs/src/reference/protocol-structure.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/react-calls-components-and-hooks.md b/docs/src/reference/react-calls-components-and-hooks.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/reactpy-csr.md b/docs/src/reference/reactpy-csr.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/reactpy-middleware.md b/docs/src/reference/reactpy-middleware.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/reactpy.md b/docs/src/reference/reactpy.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/rules-of-hooks.md b/docs/src/reference/rules-of-hooks.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/rules-of-react.md b/docs/src/reference/rules-of-react.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/usage.md b/docs/src/reference/usage.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/use-async-effect.md b/docs/src/reference/use-async-effect.md new file mode 100644 index 000000000..c11ea69cd --- /dev/null +++ b/docs/src/reference/use-async-effect.md @@ -0,0 +1,1860 @@ +## Overview + +<p class="intro" markdown> + +`useEffect` is a React Hook that lets you [synchronize a component with an external system.](/learn/synchronizing-with-effects) + +```js +useEffect(setup, dependencies?) +``` + +</p> + +--- + +## Reference + +### `useEffect(setup, dependencies?)` + +Call `useEffect` at the top level of your component to declare an Effect: + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); + // ... +} +``` + +[See more examples below.](#usage) + +#### Parameters + +- `setup`: The function with your Effect's logic. Your setup function may also optionally return a _cleanup_ function. When your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values. After your component is removed from the DOM, React will run your cleanup function. + +- **optional** `dependencies`: The list of all reactive values referenced inside of the `setup` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. If you omit this argument, your Effect will re-run after every re-render of the component. [See the difference between passing an array of dependencies, an empty array, and no dependencies at all.](#examples-dependencies) + +#### Returns + +`useEffect` returns `undefined`. + +#### Caveats + +- `useEffect` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. + +- If you're **not trying to synchronize with some external system,** [you probably don't need an Effect.](/learn/you-might-not-need-an-effect) + +- When Strict Mode is on, React will **run one extra development-only setup+cleanup cycle** before the first real setup. This is a stress-test that ensures that your cleanup logic "mirrors" your setup logic and that it stops or undoes whatever the setup is doing. If this causes a problem, [implement the cleanup function.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +- If some of your dependencies are objects or functions defined inside the component, there is a risk that they will **cause the Effect to re-run more often than needed.** To fix this, remove unnecessary [object](#removing-unnecessary-object-dependencies) and [function](#removing-unnecessary-function-dependencies) dependencies. You can also [extract state updates](#updating-state-based-on-previous-state-from-an-effect) and [non-reactive logic](#reading-the-latest-props-and-state-from-an-effect) outside of your Effect. + +- If your Effect wasn't caused by an interaction (like a click), React will let the browser **paint the updated screen first before running your Effect.** If your Effect is doing something visual (for example, positioning a tooltip), and the delay is noticeable (for example, it flickers), replace `useEffect` with [`useLayoutEffect`.](/reference/react/useLayoutEffect) + +- Even if your Effect was caused by an interaction (like a click), **the browser may repaint the screen before processing the state updates inside your Effect.** Usually, that's what you want. However, if you must block the browser from repainting the screen, you need to replace `useEffect` with [`useLayoutEffect`.](/reference/react/useLayoutEffect) + +- Effects **only run on the client.** They don't run during server rendering. + +--- + +## Usage + +### Connecting to an external system + +Some components need to stay connected to the network, some browser API, or a third-party library, while they are displayed on the page. These systems aren't controlled by React, so they are called _external._ + +To [connect your component to some external system,](/learn/synchronizing-with-effects) call `useEffect` at the top level of your component: + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); + // ... +} +``` + +You need to pass two arguments to `useEffect`: + +1. A _setup function_ with <CodeStep step={1}>setup code</CodeStep> that connects to that system. + - It should return a _cleanup function_ with <CodeStep step={2}>cleanup code</CodeStep> that disconnects from that system. +2. A <CodeStep step={3}>list of dependencies</CodeStep> including every value from your component used inside of those functions. + +**React calls your setup and cleanup functions whenever it's necessary, which may happen multiple times:** + +1. Your <CodeStep step={1}>setup code</CodeStep> runs when your component is added to the page _(mounts)_. +2. After every re-render of your component where the <CodeStep step={3}>dependencies</CodeStep> have changed: + - First, your <CodeStep step={2}>cleanup code</CodeStep> runs with the old props and state. + - Then, your <CodeStep step={1}>setup code</CodeStep> runs with the new props and state. +3. Your <CodeStep step={2}>cleanup code</CodeStep> runs one final time after your component is removed from the page _(unmounts)._ + +**Let's illustrate this sequence for the example above.** + +When the `ChatRoom` component above gets added to the page, it will connect to the chat room with the initial `serverUrl` and `roomId`. If either `serverUrl` or `roomId` change as a result of a re-render (say, if the user picks a different chat room in a dropdown), your Effect will _disconnect from the previous room, and connect to the next one._ When the `ChatRoom` component is removed from the page, your Effect will disconnect one last time. + +**To [help you find bugs,](/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed) in development React runs <CodeStep step={1}>setup</CodeStep> and <CodeStep step={2}>cleanup</CodeStep> one extra time before the <CodeStep step={1}>setup</CodeStep>.** This is a stress-test that verifies your Effect's logic is implemented correctly. If this causes visible issues, your cleanup function is missing some logic. The cleanup function should stop or undo whatever the setup function was doing. The rule of thumb is that the user shouldn't be able to distinguish between the setup being called once (as in production) and a _setup_ → _cleanup_ → _setup_ sequence (as in development). [See common solutions.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +**Try to [write every Effect as an independent process](/learn/lifecycle-of-reactive-effects#each-effect-represents-a-separate-synchronization-process) and [think about a single setup/cleanup cycle at a time.](/learn/lifecycle-of-reactive-effects#thinking-from-the-effects-perspective)** It shouldn't matter whether your component is mounting, updating, or unmounting. When your cleanup logic correctly "mirrors" the setup logic, your Effect is resilient to running setup and cleanup as often as needed. + +<Note> + +An Effect lets you [keep your component synchronized](/learn/synchronizing-with-effects) with some external system (like a chat service). Here, _external system_ means any piece of code that's not controlled by React, such as: + +- A timer managed with <CodeStep step={1}>[`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval)</CodeStep> and <CodeStep step={2}>[`clearInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval)</CodeStep>. +- An event subscription using <CodeStep step={1}>[`window.addEventListener()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener)</CodeStep> and <CodeStep step={2}>[`window.removeEventListener()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener)</CodeStep>. +- A third-party animation library with an API like <CodeStep step={1}>`animation.start()`</CodeStep> and <CodeStep step={2}>`animation.reset()`</CodeStep>. + +**If you're not connecting to any external system, [you probably don't need an Effect.](/learn/you-might-not-need-an-effect)** + +</Note> + +<Recipes titleText="Examples of connecting to an external system" titleId="examples-connecting"> + +#### Connecting to a chat server + +In this example, the `ChatRoom` component uses an Effect to stay connected to an external system defined in `chat.js`. Press "Open chat" to make the `ChatRoom` component appear. This sandbox runs in development mode, so there is an extra connect-and-disconnect cycle, as [explained here.](/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed) Try changing the `roomId` and `serverUrl` using the dropdown and the input, and see how the Effect re-connects to the chat. Press "Close chat" to see the Effect disconnect one last time. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [show, setShow] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <button onClick={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +#### Listening to a global browser event + +In this example, the external system is the browser DOM itself. Normally, you'd specify event listeners with JSX, but you can't listen to the global [`window`](https://developer.mozilla.org/en-US/docs/Web/API/Window) object this way. An Effect lets you connect to the `window` object and listen to its events. Listening to the `pointermove` event lets you track the cursor (or finger) position and update the red dot to move with it. + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + window.addEventListener("pointermove", handleMove); + return () => { + window.removeEventListener("pointermove", handleMove); + }; + }, []); + + return ( + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + ); +} +``` + +```css +body { + min-height: 300px; +} +``` + +#### Triggering an animation + +In this example, the external system is the animation library in `animation.js`. It provides a JavaScript class called `FadeInAnimation` that takes a DOM node as an argument and exposes `start()` and `stop()` methods to control the animation. This component [uses a ref](/learn/manipulating-the-dom-with-refs) to access the underlying DOM node. The Effect reads the DOM node from the ref and automatically starts the animation for that node when the component appears. + +```js +import { useState, useEffect, useRef } from "react"; +import { FadeInAnimation } from "./animation.js"; + +function Welcome() { + const ref = useRef(null); + + useEffect(() => { + const animation = new FadeInAnimation(ref.current); + animation.start(1000); + return () => { + animation.stop(); + }; + }, []); + + return ( + <h1 + ref={ref} + style={{ + opacity: 0, + color: "white", + padding: 50, + textAlign: "center", + fontSize: 50, + backgroundImage: + "radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%)", + }} + > + Welcome + </h1> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button onClick={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```js +export class FadeInAnimation { + constructor(node) { + this.node = node; + } + start(duration) { + this.duration = duration; + if (this.duration === 0) { + // Jump to end immediately + this.onProgress(1); + } else { + this.onProgress(0); + // Start animating + this.startTime = performance.now(); + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onFrame() { + const timePassed = performance.now() - this.startTime; + const progress = Math.min(timePassed / this.duration, 1); + this.onProgress(progress); + if (progress < 1) { + // We still have more frames to paint + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onProgress(progress) { + this.node.style.opacity = progress; + } + stop() { + cancelAnimationFrame(this.frameId); + this.startTime = null; + this.frameId = null; + this.duration = 0; + } +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +``` + +#### Controlling a modal dialog + +In this example, the external system is the browser DOM. The `ModalDialog` component renders a [`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) element. It uses an Effect to synchronize the `isOpen` prop to the [`showModal()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) and [`close()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close) method calls. + +```js +import { useState } from "react"; +import ModalDialog from "./ModalDialog.js"; + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button onClick={() => setShow(true)}>Open dialog</button> + <ModalDialog isOpen={show}> + Hello there! + <br /> + <button + onClick={() => { + setShow(false); + }} + > + Close + </button> + </ModalDialog> + </> + ); +} +``` + +```js +import { useEffect, useRef } from "react"; + +export default function ModalDialog({ isOpen, children }) { + const ref = useRef(); + + useEffect(() => { + if (!isOpen) { + return; + } + const dialog = ref.current; + dialog.showModal(); + return () => { + dialog.close(); + }; + }, [isOpen]); + + return <dialog ref={ref}>{children}</dialog>; +} +``` + +```css +body { + min-height: 300px; +} +``` + +#### Tracking element visibility + +In this example, the external system is again the browser DOM. The `App` component displays a long list, then a `Box` component, and then another long list. Scroll the list down. Notice that when the `Box` component appears in the viewport, the background color changes to black. To implement this, the `Box` component uses an Effect to manage an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). This browser API notifies you when the DOM element is visible in the viewport. + +```js +import Box from "./Box.js"; + +export default function App() { + return ( + <> + <LongSection /> + <Box /> + <LongSection /> + <Box /> + <LongSection /> + </> + ); +} + +function LongSection() { + const items = []; + for (let i = 0; i < 50; i++) { + items.push(<li key={i}>Item #{i} (keep scrolling)</li>); + } + return <ul>{items}</ul>; +} +``` + +```js +import { useRef, useEffect } from "react"; + +export default function Box() { + const ref = useRef(null); + + useEffect(() => { + const div = ref.current; + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + if (entry.isIntersecting) { + document.body.style.backgroundColor = "black"; + document.body.style.color = "white"; + } else { + document.body.style.backgroundColor = "white"; + document.body.style.color = "black"; + } + }); + observer.observe(div, { + threshold: 1.0, + }); + return () => { + observer.disconnect(); + }; + }, []); + + return ( + <div + ref={ref} + style={{ + margin: 20, + height: 100, + width: 100, + border: "2px solid black", + backgroundColor: "blue", + }} + /> + ); +} +``` + +</Recipes> + +--- + +### Wrapping Effects in custom Hooks + +Effects are an ["escape hatch":](/learn/escape-hatches) you use them when you need to "step outside React" and when there is no better built-in solution for your use case. If you find yourself often needing to manually write Effects, it's usually a sign that you need to extract some [custom Hooks](/learn/reusing-logic-with-custom-hooks) for common behaviors your components rely on. + +For example, this `useChatRoom` custom Hook "hides" the logic of your Effect behind a more declarative API: + +```js +function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +Then you can use it from any component like this: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + // ... +``` + +There are also many excellent custom Hooks for every purpose available in the React ecosystem. + +[Learn more about wrapping Effects in custom Hooks.](/learn/reusing-logic-with-custom-hooks) + +<Recipes titleText="Examples of wrapping Effects in custom Hooks" titleId="examples-custom-hooks"> + +#### Custom `useChatRoom` Hook + +This example is identical to one of the [earlier examples,](#examples-connecting) but the logic is extracted to a custom Hook. + +```js +import { useState } from "react"; +import { useChatRoom } from "./useChatRoom.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + }); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [show, setShow] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <button onClick={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +#### Custom `useWindowListener` Hook + +This example is identical to one of the [earlier examples,](#examples-connecting) but the logic is extracted to a custom Hook. + +```js +import { useState } from "react"; +import { useWindowListener } from "./useWindowListener.js"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useWindowListener("pointermove", (e) => { + setPosition({ x: e.clientX, y: e.clientY }); + }); + + return ( + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useWindowListener(eventType, listener) { + useEffect(() => { + window.addEventListener(eventType, listener); + return () => { + window.removeEventListener(eventType, listener); + }; + }, [eventType, listener]); +} +``` + +```css +body { + min-height: 300px; +} +``` + +#### Custom `useIntersectionObserver` Hook + +This example is identical to one of the [earlier examples,](#examples-connecting) but the logic is partially extracted to a custom Hook. + +```js +import Box from "./Box.js"; + +export default function App() { + return ( + <> + <LongSection /> + <Box /> + <LongSection /> + <Box /> + <LongSection /> + </> + ); +} + +function LongSection() { + const items = []; + for (let i = 0; i < 50; i++) { + items.push(<li key={i}>Item #{i} (keep scrolling)</li>); + } + return <ul>{items}</ul>; +} +``` + +```js +import { useRef, useEffect } from "react"; +import { useIntersectionObserver } from "./useIntersectionObserver.js"; + +export default function Box() { + const ref = useRef(null); + const isIntersecting = useIntersectionObserver(ref); + + useEffect(() => { + if (isIntersecting) { + document.body.style.backgroundColor = "black"; + document.body.style.color = "white"; + } else { + document.body.style.backgroundColor = "white"; + document.body.style.color = "black"; + } + }, [isIntersecting]); + + return ( + <div + ref={ref} + style={{ + margin: 20, + height: 100, + width: 100, + border: "2px solid black", + backgroundColor: "blue", + }} + /> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useIntersectionObserver(ref) { + const [isIntersecting, setIsIntersecting] = useState(false); + + useEffect(() => { + const div = ref.current; + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + setIsIntersecting(entry.isIntersecting); + }); + observer.observe(div, { + threshold: 1.0, + }); + return () => { + observer.disconnect(); + }; + }, [ref]); + + return isIntersecting; +} +``` + +</Recipes> + +--- + +### Controlling a non-React widget + +Sometimes, you want to keep an external system synchronized to some prop or state of your component. + +For example, if you have a third-party map widget or a video player component written without React, you can use an Effect to call methods on it that make its state match the current state of your React component. This Effect creates an instance of a `MapWidget` class defined in `map-widget.js`. When you change the `zoomLevel` prop of the `Map` component, the Effect calls the `setZoom()` on the class instance to keep it synchronized: + +```json package.json hidden +{ + "dependencies": { + "leaflet": "1.9.1", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "remarkable": "2.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState } from "react"; +import Map from "./Map.js"; + +export default function App() { + const [zoomLevel, setZoomLevel] = useState(0); + return ( + <> + Zoom level: {zoomLevel}x + <button onClick={() => setZoomLevel(zoomLevel + 1)}>+</button> + <button onClick={() => setZoomLevel(zoomLevel - 1)}>-</button> + <hr /> + <Map zoomLevel={zoomLevel} /> + </> + ); +} +``` + +```js +import { useRef, useEffect } from "react"; +import { MapWidget } from "./map-widget.js"; + +export default function Map({ zoomLevel }) { + const containerRef = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + if (mapRef.current === null) { + mapRef.current = new MapWidget(containerRef.current); + } + + const map = mapRef.current; + map.setZoom(zoomLevel); + }, [zoomLevel]); + + return <div style={{ width: 200, height: 200 }} ref={containerRef} />; +} +``` + +```js +import "leaflet/dist/leaflet.css"; +import * as L from "leaflet"; + +export class MapWidget { + constructor(domNode) { + this.map = L.map(domNode, { + zoomControl: false, + doubleClickZoom: false, + boxZoom: false, + keyboard: false, + scrollWheelZoom: false, + zoomAnimation: false, + touchZoom: false, + zoomSnap: 0.1, + }); + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap", + }).addTo(this.map); + this.map.setView([0, 0], 0); + } + setZoom(level) { + this.map.setZoom(level); + } +} +``` + +```css +button { + margin: 5px; +} +``` + +In this example, a cleanup function is not needed because the `MapWidget` class manages only the DOM node that was passed to it. After the `Map` React component is removed from the tree, both the DOM node and the `MapWidget` class instance will be automatically garbage-collected by the browser JavaScript engine. + +--- + +### Fetching data with Effects + +You can use an Effect to fetch data for your component. Note that [if you use a framework,](/learn/start-a-new-react-project#production-grade-react-frameworks) using your framework's data fetching mechanism will be a lot more efficient than writing Effects manually. + +If you want to fetch data from an Effect manually, your code might look like this: + +```js +import { useState, useEffect } from 'react'; +import { fetchBio } from './api.js'; + +export default function Page() { + const [person, setPerson] = useState('Alice'); + const [bio, setBio] = useState(null); + + useEffect(() => { + let ignore = false; + setBio(null); + fetchBio(person).then(result => { + if (!ignore) { + setBio(result); + } + }); + return () => { + ignore = true; + }; + }, [person]); + + // ... +``` + +Note the `ignore` variable which is initialized to `false`, and is set to `true` during cleanup. This ensures [your code doesn't suffer from "race conditions":](https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect) network responses may arrive in a different order than you sent them. + +```js +import { useState, useEffect } from "react"; +import { fetchBio } from "./api.js"; + +export default function Page() { + const [person, setPerson] = useState("Alice"); + const [bio, setBio] = useState(null); + useEffect(() => { + let ignore = false; + setBio(null); + fetchBio(person).then((result) => { + if (!ignore) { + setBio(result); + } + }); + return () => { + ignore = true; + }; + }, [person]); + + return ( + <> + <select + value={person} + onChange={(e) => { + setPerson(e.target.value); + }} + > + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + <option value="Taylor">Taylor</option> + </select> + <hr /> + <p> + <i>{bio ?? "Loading..."}</i> + </p> + </> + ); +} +``` + +```js +export async function fetchBio(person) { + const delay = person === "Bob" ? 2000 : 200; + return new Promise((resolve) => { + setTimeout(() => { + resolve("This is " + person + "’s bio."); + }, delay); + }); +} +``` + +You can also rewrite using the [`async` / `await`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) syntax, but you still need to provide a cleanup function: + +```js +import { useState, useEffect } from "react"; +import { fetchBio } from "./api.js"; + +export default function Page() { + const [person, setPerson] = useState("Alice"); + const [bio, setBio] = useState(null); + useEffect(() => { + async function startFetching() { + setBio(null); + const result = await fetchBio(person); + if (!ignore) { + setBio(result); + } + } + + let ignore = false; + startFetching(); + return () => { + ignore = true; + }; + }, [person]); + + return ( + <> + <select + value={person} + onChange={(e) => { + setPerson(e.target.value); + }} + > + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + <option value="Taylor">Taylor</option> + </select> + <hr /> + <p> + <i>{bio ?? "Loading..."}</i> + </p> + </> + ); +} +``` + +```js +export async function fetchBio(person) { + const delay = person === "Bob" ? 2000 : 200; + return new Promise((resolve) => { + setTimeout(() => { + resolve("This is " + person + "’s bio."); + }, delay); + }); +} +``` + +Writing data fetching directly in Effects gets repetitive and makes it difficult to add optimizations like caching and server rendering later. [It's easier to use a custom Hook--either your own or maintained by the community.](/learn/reusing-logic-with-custom-hooks#when-to-use-custom-hooks) + +<DeepDive> + +#### What are good alternatives to data fetching in Effects? + +Writing `fetch` calls inside Effects is a [popular way to fetch data](https://www.robinwieruch.de/react-hooks-fetch-data/), especially in fully client-side apps. This is, however, a very manual approach and it has significant downsides: + +- **Effects don't run on the server.** This means that the initial server-rendered HTML will only include a loading state with no data. The client computer will have to download all JavaScript and render your app only to discover that now it needs to load the data. This is not very efficient. +- **Fetching directly in Effects makes it easy to create "network waterfalls".** You render the parent component, it fetches some data, renders the child components, and then they start fetching their data. If the network is not very fast, this is significantly slower than fetching all data in parallel. +- **Fetching directly in Effects usually means you don't preload or cache data.** For example, if the component unmounts and then mounts again, it would have to fetch the data again. +- **It's not very ergonomic.** There's quite a bit of boilerplate code involved when writing `fetch` calls in a way that doesn't suffer from bugs like [race conditions.](https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect) + +This list of downsides is not specific to React. It applies to fetching data on mount with any library. Like with routing, data fetching is not trivial to do well, so we recommend the following approaches: + +- **If you use a [framework](/learn/start-a-new-react-project#production-grade-react-frameworks), use its built-in data fetching mechanism.** Modern React frameworks have integrated data fetching mechanisms that are efficient and don't suffer from the above pitfalls. +- **Otherwise, consider using or building a client-side cache.** Popular open source solutions include [React Query](https://react-query.tanstack.com/), [useSWR](https://swr.vercel.app/), and [React Router 6.4+.](https://beta.reactrouter.com/en/main/start/overview) You can build your own solution too, in which case you would use Effects under the hood but also add logic for deduplicating requests, caching responses, and avoiding network waterfalls (by preloading data or hoisting data requirements to routes). + +You can continue fetching data directly in Effects if neither of these approaches suit you. + +</DeepDive> + +--- + +### Specifying reactive dependencies + +**Notice that you can't "choose" the dependencies of your Effect.** Every <CodeStep step={2}>reactive value</CodeStep> used by your Effect's code must be declared as a dependency. Your Effect's dependency list is determined by the surrounding code: + +```js +function ChatRoom({ roomId }) { + // This is a reactive value + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); // This is a reactive value too + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // This Effect reads these reactive values + connection.connect(); + return () => connection.disconnect(); + }, [serverUrl, roomId]); // ✅ So you must specify them as dependencies of your Effect + // ... +} +``` + +If either `serverUrl` or `roomId` change, your Effect will reconnect to the chat using the new values. + +**[Reactive values](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) include props and all variables and functions declared directly inside of your component.** Since `roomId` and `serverUrl` are reactive values, you can't remove them from the dependencies. If you try to omit them and [your linter is correctly configured for React,](/learn/editor-setup#linting) the linter will flag this as a mistake you need to fix: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // 🔴 React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl' + // ... +} +``` + +**To remove a dependency, you need to ["prove" to the linter that it _doesn't need_ to be a dependency.](/learn/removing-effect-dependencies#removing-unnecessary-dependencies)** For example, you can move `serverUrl` out of your component to prove that it's not reactive and won't change on re-renders: + +```js +const serverUrl = "https://localhost:1234"; // Not a reactive value anymore + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +} +``` + +Now that `serverUrl` is not a reactive value (and can't change on a re-render), it doesn't need to be a dependency. **If your Effect's code doesn't use any reactive values, its dependency list should be empty (`[]`):** + +```js +const serverUrl = "https://localhost:1234"; // Not a reactive value anymore +const roomId = "music"; // Not a reactive value anymore + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // ✅ All dependencies declared + // ... +} +``` + +[An Effect with empty dependencies](/learn/lifecycle-of-reactive-effects#what-an-effect-with-empty-dependencies-means) doesn't re-run when any of your component's props or state change. + +<Pitfall> + +If you have an existing codebase, you might have some Effects that suppress the linter like this: + +```js +useEffect(() => { + // ... + // 🔴 Avoid suppressing the linter like this: + // eslint-ignore-next-line react-hooks/exhaustive-deps +}, []); +``` + +**When dependencies don't match the code, there is a high risk of introducing bugs.** By suppressing the linter, you "lie" to React about the values your Effect depends on. [Instead, prove they're unnecessary.](/learn/removing-effect-dependencies#removing-unnecessary-dependencies) + +</Pitfall> + +<Recipes titleText="Examples of passing reactive dependencies" titleId="examples-dependencies"> + +#### Passing a dependency array + +If you specify the dependencies, your Effect runs **after the initial render _and_ after re-renders with changed dependencies.** + +```js +useEffect(() => { + // ... +}, [a, b]); // Runs again if a or b are different +``` + +In the below example, `serverUrl` and `roomId` are [reactive values,](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) so they both must be specified as dependencies. As a result, selecting a different room in the dropdown or editing the server URL input causes the chat to re-connect. However, since `message` isn't used in the Effect (and so it isn't a dependency), editing the message doesn't re-connect to the chat. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + <label> + Your message:{" "} + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </label> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> + </label> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + margin-bottom: 10px; +} +button { + margin-left: 5px; +} +``` + +#### Passing an empty dependency array + +If your Effect truly doesn't use any reactive values, it will only run **after the initial render.** + +```js +useEffect(() => { + // ... +}, []); // Does not run again (except once in development) +``` + +**Even with empty dependencies, setup and cleanup will [run one extra time in development](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) to help you find bugs.** + +In this example, both `serverUrl` and `roomId` are hardcoded. Since they're declared outside the component, they are not reactive values, and so they aren't dependencies. The dependency list is empty, so the Effect doesn't re-run on re-renders. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; +const roomId = "music"; + +function ChatRoom() { + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <label> + Your message:{" "} + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </label> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button onClick={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +#### Passing no dependency array at all + +If you pass no dependency array at all, your Effect runs **after every single render (and re-render)** of your component. + +```js +useEffect(() => { + // ... +}); // Always runs again +``` + +In this example, the Effect re-runs when you change `serverUrl` and `roomId`, which is sensible. However, it _also_ re-runs when you change the `message`, which is probably undesirable. This is why usually you'll specify the dependency array. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }); // No dependency array at all + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + <label> + Your message:{" "} + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </label> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> + </label> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + margin-bottom: 10px; +} +button { + margin-left: 5px; +} +``` + +</Recipes> + +--- + +### Updating state based on previous state from an Effect + +When you want to update state based on previous state from an Effect, you might run into a problem: + +```js +function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + const intervalId = setInterval(() => { + setCount(count + 1); // You want to increment the counter every second... + }, 1000); + return () => clearInterval(intervalId); + }, [count]); // 🚩 ... but specifying `count` as a dependency always resets the interval. + // ... +} +``` + +Since `count` is a reactive value, it must be specified in the list of dependencies. However, that causes the Effect to cleanup and setup again every time the `count` changes. This is not ideal. + +To fix this, [pass the `c => c + 1` state updater](/reference/react/useState#updating-state-based-on-the-previous-state) to `setCount`: + +```js +import { useState, useEffect } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + const intervalId = setInterval(() => { + setCount((c) => c + 1); // ✅ Pass a state updater + }, 1000); + return () => clearInterval(intervalId); + }, []); // ✅ Now count is not a dependency + + return <h1>{count}</h1>; +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +Now that you're passing `c => c + 1` instead of `count + 1`, [your Effect no longer needs to depend on `count`.](/learn/removing-effect-dependencies#are-you-reading-some-state-to-calculate-the-next-state) As a result of this fix, it won't need to cleanup and setup the interval again every time the `count` changes. + +--- + +### Removing unnecessary object dependencies + +If your Effect depends on an object or a function created during rendering, it might run too often. For example, this Effect re-connects after every render because the `options` object is [different for every render:](/learn/removing-effect-dependencies#does-some-reactive-value-change-unintentionally) + +```js +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + const options = { // 🚩 This object is created from scratch on every re-render + serverUrl: serverUrl, + roomId: roomId + }; + + useEffect(() => { + const connection = createConnection(options); // It's used inside the Effect + connection.connect(); + return () => connection.disconnect(); + }, [options]); // 🚩 As a result, these dependencies are always different on a re-render + // ... +``` + +Avoid using an object created during rendering as a dependency. Instead, create the object inside the Effect: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Now that you create the `options` object inside the Effect, the Effect itself only depends on the `roomId` string. + +With this fix, typing into the input doesn't reconnect the chat. Unlike an object which gets re-created, a string like `roomId` doesn't change unless you set it to another value. [Read more about removing dependencies.](/learn/removing-effect-dependencies) + +--- + +### Removing unnecessary function dependencies + +If your Effect depends on an object or a function created during rendering, it might run too often. For example, this Effect re-connects after every render because the `createOptions` function is [different for every render:](/learn/removing-effect-dependencies#does-some-reactive-value-change-unintentionally) + +```js +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + function createOptions() { // 🚩 This function is created from scratch on every re-render + return { + serverUrl: serverUrl, + roomId: roomId + }; + } + + useEffect(() => { + const options = createOptions(); // It's used inside the Effect + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); + }, [createOptions]); // 🚩 As a result, these dependencies are always different on a re-render + // ... +``` + +By itself, creating a function from scratch on every re-render is not a problem. You don't need to optimize that. However, if you use it as a dependency of your Effect, it will cause your Effect to re-run after every re-render. + +Avoid using a function created during rendering as a dependency. Instead, declare it inside the Effect: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + function createOptions() { + return { + serverUrl: serverUrl, + roomId: roomId, + }; + } + + const options = createOptions(); + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Now that you define the `createOptions` function inside the Effect, the Effect itself only depends on the `roomId` string. With this fix, typing into the input doesn't reconnect the chat. Unlike a function which gets re-created, a string like `roomId` doesn't change unless you set it to another value. [Read more about removing dependencies.](/learn/removing-effect-dependencies) + +--- + +### Reading the latest props and state from an Effect + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +By default, when you read a reactive value from an Effect, you have to add it as a dependency. This ensures that your Effect "reacts" to every change of that value. For most dependencies, that's the behavior you want. + +**However, sometimes you'll want to read the _latest_ props and state from an Effect without "reacting" to them.** For example, imagine you want to log the number of the items in the shopping cart for every page visit: + +```js +function Page({ url, shoppingCart }) { + useEffect(() => { + logVisit(url, shoppingCart.length); + }, [url, shoppingCart]); // ✅ All dependencies declared + // ... +} +``` + +**What if you want to log a new page visit after every `url` change, but _not_ if only the `shoppingCart` changes?** You can't exclude `shoppingCart` from dependencies without breaking the [reactivity rules.](#specifying-reactive-dependencies) However, you can express that you _don't want_ a piece of code to "react" to changes even though it is called from inside an Effect. [Declare an _Effect Event_](/learn/separating-events-from-effects#declaring-an-effect-event) with the [`useEffectEvent`](/reference/react/experimental_useEffectEvent) Hook, and move the code reading `shoppingCart` inside of it: + +```js +function Page({ url, shoppingCart }) { + const onVisit = useEffectEvent((visitedUrl) => { + logVisit(visitedUrl, shoppingCart.length); + }); + + useEffect(() => { + onVisit(url); + }, [url]); // ✅ All dependencies declared + // ... +} +``` + +**Effect Events are not reactive and must always be omitted from dependencies of your Effect.** This is what lets you put non-reactive code (where you can read the latest value of some props and state) inside of them. By reading `shoppingCart` inside of `onVisit`, you ensure that `shoppingCart` won't re-run your Effect. + +[Read more about how Effect Events let you separate reactive and non-reactive code.](/learn/separating-events-from-effects#reading-latest-props-and-state-with-effect-events) + +--- + +### Displaying different content on the server and the client + +If your app uses server rendering (either [directly](/reference/react-dom/server) or via a [framework](/learn/start-a-new-react-project#production-grade-react-frameworks)), your component will render in two different environments. On the server, it will render to produce the initial HTML. On the client, React will run the rendering code again so that it can attach your event handlers to that HTML. This is why, for [hydration](/reference/react-dom/client/hydrateRoot#hydrating-server-rendered-html) to work, your initial render output must be identical on the client and the server. + +In rare cases, you might need to display different content on the client. For example, if your app reads some data from [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage), it can't possibly do that on the server. Here is how you could implement this: + +```js +function MyComponent() { + const [didMount, setDidMount] = useState(false); + + useEffect(() => { + setDidMount(true); + }, []); + + if (didMount) { + // ... return client-only JSX ... + } else { + // ... return initial JSX ... + } +} +``` + +While the app is loading, the user will see the initial render output. Then, when it's loaded and hydrated, your Effect will run and set `didMount` to `true`, triggering a re-render. This will switch to the client-only render output. Effects don't run on the server, so this is why `didMount` was `false` during the initial server render. + +Use this pattern sparingly. Keep in mind that users with a slow connection will see the initial content for quite a bit of time--potentially, many seconds--so you don't want to make jarring changes to your component's appearance. In many cases, you can avoid the need for this by conditionally showing different things with CSS. + +--- + +## Troubleshooting + +### My Effect runs twice when the component mounts + +When Strict Mode is on, in development, React runs setup and cleanup one extra time before the actual setup. + +This is a stress-test that verifies your Effect’s logic is implemented correctly. If this causes visible issues, your cleanup function is missing some logic. The cleanup function should stop or undo whatever the setup function was doing. The rule of thumb is that the user shouldn’t be able to distinguish between the setup being called once (as in production) and a setup → cleanup → setup sequence (as in development). + +Read more about [how this helps find bugs](/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed) and [how to fix your logic.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +--- + +### My Effect runs after every re-render + +First, check that you haven't forgotten to specify the dependency array: + +```js +useEffect(() => { + // ... +}); // 🚩 No dependency array: re-runs after every render! +``` + +If you've specified the dependency array but your Effect still re-runs in a loop, it's because one of your dependencies is different on every re-render. + +You can debug this problem by manually logging your dependencies to the console: + +```js +useEffect(() => { + // .. +}, [serverUrl, roomId]); + +console.log([serverUrl, roomId]); +``` + +You can then right-click on the arrays from different re-renders in the console and select "Store as a global variable" for both of them. Assuming the first one got saved as `temp1` and the second one got saved as `temp2`, you can then use the browser console to check whether each dependency in both arrays is the same: + +```js +Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays? +Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays? +Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ... +``` + +When you find the dependency that is different on every re-render, you can usually fix it in one of these ways: + +- [Updating state based on previous state from an Effect](#updating-state-based-on-previous-state-from-an-effect) +- [Removing unnecessary object dependencies](#removing-unnecessary-object-dependencies) +- [Removing unnecessary function dependencies](#removing-unnecessary-function-dependencies) +- [Reading the latest props and state from an Effect](#reading-the-latest-props-and-state-from-an-effect) + +As a last resort (if these methods didn't help), wrap its creation with [`useMemo`](/reference/react/useMemo#memoizing-a-dependency-of-another-hook) or [`useCallback`](/reference/react/useCallback#preventing-an-effect-from-firing-too-often) (for functions). + +--- + +### My Effect keeps re-running in an infinite cycle + +If your Effect runs in an infinite cycle, these two things must be true: + +- Your Effect is updating some state. +- That state leads to a re-render, which causes the Effect's dependencies to change. + +Before you start fixing the problem, ask yourself whether your Effect is connecting to some external system (like DOM, network, a third-party widget, and so on). Why does your Effect need to set state? Does it synchronize with that external system? Or are you trying to manage your application's data flow with it? + +If there is no external system, consider whether [removing the Effect altogether](/learn/you-might-not-need-an-effect) would simplify your logic. + +If you're genuinely synchronizing with some external system, think about why and under what conditions your Effect should update the state. Has something changed that affects your component's visual output? If you need to keep track of some data that isn't used by rendering, a [ref](/reference/react/useRef#referencing-a-value-with-a-ref) (which doesn't trigger re-renders) might be more appropriate. Verify your Effect doesn't update the state (and trigger re-renders) more than needed. + +Finally, if your Effect is updating the state at the right time, but there is still a loop, it's because that state update leads to one of the Effect's dependencies changing. [Read how to debug dependency changes.](/reference/react/useEffect#my-effect-runs-after-every-re-render) + +--- + +### My cleanup logic runs even though my component didn't unmount + +The cleanup function runs not only during unmount, but before every re-render with changed dependencies. Additionally, in development, React [runs setup+cleanup one extra time immediately after component mounts.](#my-effect-runs-twice-when-the-component-mounts) + +If you have cleanup code without corresponding setup code, it's usually a code smell: + +```js +useEffect(() => { + // 🔴 Avoid: Cleanup logic without corresponding setup logic + return () => { + doSomething(); + }; +}, []); +``` + +Your cleanup logic should be "symmetrical" to the setup logic, and should stop or undo whatever setup did: + +```js +useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; +}, [serverUrl, roomId]); +``` + +[Learn how the Effect lifecycle is different from the component's lifecycle.](/learn/lifecycle-of-reactive-effects#the-lifecycle-of-an-effect) + +--- + +### My Effect does something visual, and I see a flicker before it runs + +If your Effect must block the browser from [painting the screen,](/learn/render-and-commit#epilogue-browser-paint) replace `useEffect` with [`useLayoutEffect`](/reference/react/useLayoutEffect). Note that **this shouldn't be needed for the vast majority of Effects.** You'll only need this if it's crucial to run your Effect before the browser paint: for example, to measure and position a tooltip before the user sees it. diff --git a/docs/src/reference/use-callback.md b/docs/src/reference/use-callback.md new file mode 100644 index 000000000..563a1089e --- /dev/null +++ b/docs/src/reference/use-callback.md @@ -0,0 +1,948 @@ +## Overview + +<p class="intro" markdown> + +`useCallback` is a React Hook that lets you cache a function definition between re-renders. + +```js +const cachedFn = useCallback(fn, dependencies); +``` + +</p> + +--- + +## Reference + +### `useCallback(fn, dependencies)` + +Call `useCallback` at the top level of your component to cache a function definition between re-renders: + +```js +import { useCallback } from 'react'; + +export default function ProductPage({ productId, referrer, theme }) { + const handleSubmit = useCallback((orderDetails) => { + post('/product/' + productId + '/buy', { + referrer, + orderDetails, + }); + }, [productId, referrer]); +``` + +[See more examples below.](#usage) + +#### Parameters + +- `fn`: The function value that you want to cache. It can take any arguments and return any values. React will return (not call!) your function back to you during the initial render. On next renders, React will give you the same function again if the `dependencies` have not changed since the last render. Otherwise, it will give you the function that you have passed during the current render, and store it in case it can be reused later. React will not call your function. The function is returned to you so you can decide when and whether to call it. + +- `dependencies`: The list of all reactive values referenced inside of the `fn` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison algorithm. + +#### Returns + +On the initial render, `useCallback` returns the `fn` function you have passed. + +During subsequent renders, it will either return an already stored `fn` function from the last render (if the dependencies haven't changed), or return the `fn` function you have passed during this render. + +#### Caveats + +- `useCallback` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. +- React **will not throw away the cached function unless there is a specific reason to do that.** For example, in development, React throws away the cache when you edit the file of your component. Both in development and in production, React will throw away the cache if your component suspends during the initial mount. In the future, React may add more features that take advantage of throwing away the cache--for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport. This should match your expectations if you rely on `useCallback` as a performance optimization. Otherwise, a [state variable](/reference/react/useState#im-trying-to-set-state-to-a-function-but-it-gets-called-instead) or a [ref](/reference/react/useRef#avoiding-recreating-the-ref-contents) may be more appropriate. + +--- + +## Usage + +### Skipping re-rendering of components + +When you optimize rendering performance, you will sometimes need to cache the functions that you pass to child components. Let's first look at the syntax for how to do this, and then see in which cases it's useful. + +To cache a function between re-renders of your component, wrap its definition into the `useCallback` Hook: + +```js +import { useCallback } from 'react'; + +function ProductPage({ productId, referrer, theme }) { + const handleSubmit = useCallback((orderDetails) => { + post('/product/' + productId + '/buy', { + referrer, + orderDetails, + }); + }, [productId, referrer]); + // ... +``` + +You need to pass two things to `useCallback`: + +1. A function definition that you want to cache between re-renders. +2. A <CodeStep step={2}>list of dependencies</CodeStep> including every value within your component that's used inside your function. + +On the initial render, the <CodeStep step={3}>returned function</CodeStep> you'll get from `useCallback` will be the function you passed. + +On the following renders, React will compare the <CodeStep step={2}>dependencies</CodeStep> with the dependencies you passed during the previous render. If none of the dependencies have changed (compared with [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)), `useCallback` will return the same function as before. Otherwise, `useCallback` will return the function you passed on _this_ render. + +In other words, `useCallback` caches a function between re-renders until its dependencies change. + +**Let's walk through an example to see when this is useful.** + +Say you're passing a `handleSubmit` function down from the `ProductPage` to the `ShippingForm` component: + +```js +function ProductPage({ productId, referrer, theme }) { + // ... + return ( + <div className={theme}> + <ShippingForm onSubmit={handleSubmit} /> + </div> + ); +``` + +You've noticed that toggling the `theme` prop freezes the app for a moment, but if you remove `<ShippingForm />` from your JSX, it feels fast. This tells you that it's worth trying to optimize the `ShippingForm` component. + +**By default, when a component re-renders, React re-renders all of its children recursively.** This is why, when `ProductPage` re-renders with a different `theme`, the `ShippingForm` component _also_ re-renders. This is fine for components that don't require much calculation to re-render. But if you verified a re-render is slow, you can tell `ShippingForm` to skip re-rendering when its props are the same as on last render by wrapping it in [`memo`:](/reference/react/memo) + +```js +import { memo } from "react"; + +const ShippingForm = memo(function ShippingForm({ onSubmit }) { + // ... +}); +``` + +**With this change, `ShippingForm` will skip re-rendering if all of its props are the _same_ as on the last render.** This is when caching a function becomes important! Let's say you defined `handleSubmit` without `useCallback`: + +```js +function ProductPage({ productId, referrer, theme }) { + // Every time the theme changes, this will be a different function... + function handleSubmit(orderDetails) { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + } + + return ( + <div className={theme}> + {/* ... so ShippingForm's props will never be the same, and it will re-render every time */} + <ShippingForm onSubmit={handleSubmit} /> + </div> + ); +} +``` + +**In JavaScript, a `function () {}` or `() => {}` always creates a _different_ function,** similar to how the `{}` object literal always creates a new object. Normally, this wouldn't be a problem, but it means that `ShippingForm` props will never be the same, and your [`memo`](/reference/react/memo) optimization won't work. This is where `useCallback` comes in handy: + +```js +function ProductPage({ productId, referrer, theme }) { + // Tell React to cache your function between re-renders... + const handleSubmit = useCallback( + (orderDetails) => { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + }, + [productId, referrer] + ); // ...so as long as these dependencies don't change... + + return ( + <div className={theme}> + {/* ...ShippingForm will receive the same props and can skip re-rendering */} + <ShippingForm onSubmit={handleSubmit} /> + </div> + ); +} +``` + +**By wrapping `handleSubmit` in `useCallback`, you ensure that it's the _same_ function between the re-renders** (until dependencies change). You don't _have to_ wrap a function in `useCallback` unless you do it for some specific reason. In this example, the reason is that you pass it to a component wrapped in [`memo`,](/reference/react/memo) and this lets it skip re-rendering. There are other reasons you might need `useCallback` which are described further on this page. + +<Note> + +**You should only rely on `useCallback` as a performance optimization.** If your code doesn't work without it, find the underlying problem and fix it first. Then you may add `useCallback` back. + +</Note> + +<DeepDive> + +#### How is useCallback related to useMemo? + +You will often see [`useMemo`](/reference/react/useMemo) alongside `useCallback`. They are both useful when you're trying to optimize a child component. They let you [memoize](https://en.wikipedia.org/wiki/Memoization) (or, in other words, cache) something you're passing down: + +```js +import { useMemo, useCallback } from "react"; + +function ProductPage({ productId, referrer }) { + const product = useData("/product/" + productId); + + const requirements = useMemo(() => { + // Calls your function and caches its result + return computeRequirements(product); + }, [product]); + + const handleSubmit = useCallback( + (orderDetails) => { + // Caches your function itself + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + }, + [productId, referrer] + ); + + return ( + <div className={theme}> + <ShippingForm requirements={requirements} onSubmit={handleSubmit} /> + </div> + ); +} +``` + +The difference is in _what_ they're letting you cache: + +- **[`useMemo`](/reference/react/useMemo) caches the _result_ of calling your function.** In this example, it caches the result of calling `computeRequirements(product)` so that it doesn't change unless `product` has changed. This lets you pass the `requirements` object down without unnecessarily re-rendering `ShippingForm`. When necessary, React will call the function you've passed during rendering to calculate the result. +- **`useCallback` caches _the function itself._** Unlike `useMemo`, it does not call the function you provide. Instead, it caches the function you provided so that `handleSubmit` _itself_ doesn't change unless `productId` or `referrer` has changed. This lets you pass the `handleSubmit` function down without unnecessarily re-rendering `ShippingForm`. Your code won't run until the user submits the form. + +If you're already familiar with [`useMemo`,](/reference/react/useMemo) you might find it helpful to think of `useCallback` as this: + +```js +// Simplified implementation (inside React) +function useCallback(fn, dependencies) { + return useMemo(() => fn, dependencies); +} +``` + +[Read more about the difference between `useMemo` and `useCallback`.](/reference/react/useMemo#memoizing-a-function) + +</DeepDive> + +<DeepDive> + +#### Should you add useCallback everywhere? + +If your app is like this site, and most interactions are coarse (like replacing a page or an entire section), memoization is usually unnecessary. On the other hand, if your app is more like a drawing editor, and most interactions are granular (like moving shapes), then you might find memoization very helpful. + +Caching a function with `useCallback` is only valuable in a few cases: + +- You pass it as a prop to a component wrapped in [`memo`.](/reference/react/memo) You want to skip re-rendering if the value hasn't changed. Memoization lets your component re-render only if dependencies changed. +- The function you're passing is later used as a dependency of some Hook. For example, another function wrapped in `useCallback` depends on it, or you depend on this function from [`useEffect.`](/reference/react/useEffect) + +There is no benefit to wrapping a function in `useCallback` in other cases. There is no significant harm to doing that either, so some teams choose to not think about individual cases, and memoize as much as possible. The downside is that code becomes less readable. Also, not all memoization is effective: a single value that's "always new" is enough to break memoization for an entire component. + +Note that `useCallback` does not prevent _creating_ the function. You're always creating a function (and that's fine!), but React ignores it and gives you back a cached function if nothing changed. + +**In practice, you can make a lot of memoization unnecessary by following a few principles:** + +1. When a component visually wraps other components, let it [accept JSX as children.](/learn/passing-props-to-a-component#passing-jsx-as-children) Then, if the wrapper component updates its own state, React knows that its children don't need to re-render. +1. Prefer local state and don't [lift state up](/learn/sharing-state-between-components) any further than necessary. Don't keep transient state like forms and whether an item is hovered at the top of your tree or in a global state library. +1. Keep your [rendering logic pure.](/learn/keeping-components-pure) If re-rendering a component causes a problem or produces some noticeable visual artifact, it's a bug in your component! Fix the bug instead of adding memoization. +1. Avoid [unnecessary Effects that update state.](/learn/you-might-not-need-an-effect) Most performance problems in React apps are caused by chains of updates originating from Effects that cause your components to render over and over. +1. Try to [remove unnecessary dependencies from your Effects.](/learn/removing-effect-dependencies) For example, instead of memoization, it's often simpler to move some object or a function inside an Effect or outside the component. + +If a specific interaction still feels laggy, [use the React Developer Tools profiler](https://legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html) to see which components benefit the most from memoization, and add memoization where needed. These principles make your components easier to debug and understand, so it's good to follow them in any case. In long term, we're researching [doing memoization automatically](https://www.youtube.com/watch?v=lGEMwh32soc) to solve this once and for all. + +</DeepDive> + +<Recipes titleText="The difference between useCallback and declaring a function directly" titleId="examples-rerendering"> + +#### Skipping re-rendering with `useCallback` and `memo` + +In this example, the `ShippingForm` component is **artificially slowed down** so that you can see what happens when a React component you're rendering is genuinely slow. Try incrementing the counter and toggling the theme. + +Incrementing the counter feels slow because it forces the slowed down `ShippingForm` to re-render. That's expected because the counter has changed, and so you need to reflect the user's new choice on the screen. + +Next, try toggling the theme. **Thanks to `useCallback` together with [`memo`](/reference/react/memo), it’s fast despite the artificial slowdown!** `ShippingForm` skipped re-rendering because the `handleSubmit` function has not changed. The `handleSubmit` function has not changed because both `productId` and `referrer` (your `useCallback` dependencies) haven't changed since last render. + +```js +import { useState } from "react"; +import ProductPage from "./ProductPage.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <ProductPage + referrerId="wizard_of_oz" + productId={123} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import { useCallback } from "react"; +import ShippingForm from "./ShippingForm.js"; + +export default function ProductPage({ productId, referrer, theme }) { + const handleSubmit = useCallback( + (orderDetails) => { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + }, + [productId, referrer] + ); + + return ( + <div className={theme}> + <ShippingForm onSubmit={handleSubmit} /> + </div> + ); +} + +function post(url, data) { + // Imagine this sends a request... + console.log("POST /" + url); + console.log(data); +} +``` + +```js +import { memo, useState } from "react"; + +const ShippingForm = memo(function ShippingForm({ onSubmit }) { + const [count, setCount] = useState(1); + + console.log("[ARTIFICIALLY SLOW] Rendering <ShippingForm />"); + let startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Do nothing for 500 ms to emulate extremely slow code + } + + function handleSubmit(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const orderDetails = { + ...Object.fromEntries(formData), + count, + }; + onSubmit(orderDetails); + } + + return ( + <form onSubmit={handleSubmit}> + <p> + <b> + Note: <code>ShippingForm</code> is artificially slowed down! + </b> + </p> + <label> + Number of items: + <button type="button" onClick={() => setCount(count - 1)}> + – + </button> + {count} + <button type="button" onClick={() => setCount(count + 1)}> + + + </button> + </label> + <label> + Street: + <input name="street" /> + </label> + <label> + City: + <input name="city" /> + </label> + <label> + Postal code: + <input name="zipCode" /> + </label> + <button type="submit">Submit</button> + </form> + ); +}); + +export default ShippingForm; +``` + +```css +label { + display: block; + margin-top: 10px; +} + +input { + margin-left: 5px; +} + +button[type="button"] { + margin: 5px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +#### Always re-rendering a component + +In this example, the `ShippingForm` implementation is also **artificially slowed down** so that you can see what happens when some React component you're rendering is genuinely slow. Try incrementing the counter and toggling the theme. + +Unlike in the previous example, toggling the theme is also slow now! This is because **there is no `useCallback` call in this version,** so `handleSubmit` is always a new function, and the slowed down `ShippingForm` component can't skip re-rendering. + +```js +import { useState } from "react"; +import ProductPage from "./ProductPage.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <ProductPage + referrerId="wizard_of_oz" + productId={123} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import ShippingForm from "./ShippingForm.js"; + +export default function ProductPage({ productId, referrer, theme }) { + function handleSubmit(orderDetails) { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + } + + return ( + <div className={theme}> + <ShippingForm onSubmit={handleSubmit} /> + </div> + ); +} + +function post(url, data) { + // Imagine this sends a request... + console.log("POST /" + url); + console.log(data); +} +``` + +```js +import { memo, useState } from "react"; + +const ShippingForm = memo(function ShippingForm({ onSubmit }) { + const [count, setCount] = useState(1); + + console.log("[ARTIFICIALLY SLOW] Rendering <ShippingForm />"); + let startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Do nothing for 500 ms to emulate extremely slow code + } + + function handleSubmit(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const orderDetails = { + ...Object.fromEntries(formData), + count, + }; + onSubmit(orderDetails); + } + + return ( + <form onSubmit={handleSubmit}> + <p> + <b> + Note: <code>ShippingForm</code> is artificially slowed down! + </b> + </p> + <label> + Number of items: + <button type="button" onClick={() => setCount(count - 1)}> + – + </button> + {count} + <button type="button" onClick={() => setCount(count + 1)}> + + + </button> + </label> + <label> + Street: + <input name="street" /> + </label> + <label> + City: + <input name="city" /> + </label> + <label> + Postal code: + <input name="zipCode" /> + </label> + <button type="submit">Submit</button> + </form> + ); +}); + +export default ShippingForm; +``` + +```css +label { + display: block; + margin-top: 10px; +} + +input { + margin-left: 5px; +} + +button[type="button"] { + margin: 5px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +However, here is the same code **with the artificial slowdown removed.** Does the lack of `useCallback` feel noticeable or not? + +```js +import { useState } from "react"; +import ProductPage from "./ProductPage.js"; + +export default function App() { + const [isDark, setIsDark] = useState(false); + return ( + <> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <ProductPage + referrerId="wizard_of_oz" + productId={123} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import ShippingForm from "./ShippingForm.js"; + +export default function ProductPage({ productId, referrer, theme }) { + function handleSubmit(orderDetails) { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + } + + return ( + <div className={theme}> + <ShippingForm onSubmit={handleSubmit} /> + </div> + ); +} + +function post(url, data) { + // Imagine this sends a request... + console.log("POST /" + url); + console.log(data); +} +``` + +```js +import { memo, useState } from "react"; + +const ShippingForm = memo(function ShippingForm({ onSubmit }) { + const [count, setCount] = useState(1); + + console.log("Rendering <ShippingForm />"); + + function handleSubmit(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const orderDetails = { + ...Object.fromEntries(formData), + count, + }; + onSubmit(orderDetails); + } + + return ( + <form onSubmit={handleSubmit}> + <label> + Number of items: + <button type="button" onClick={() => setCount(count - 1)}> + – + </button> + {count} + <button type="button" onClick={() => setCount(count + 1)}> + + + </button> + </label> + <label> + Street: + <input name="street" /> + </label> + <label> + City: + <input name="city" /> + </label> + <label> + Postal code: + <input name="zipCode" /> + </label> + <button type="submit">Submit</button> + </form> + ); +}); + +export default ShippingForm; +``` + +```css +label { + display: block; + margin-top: 10px; +} + +input { + margin-left: 5px; +} + +button[type="button"] { + margin: 5px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +Quite often, code without memoization works fine. If your interactions are fast enough, you don't need memoization. + +Keep in mind that you need to run React in production mode, disable [React Developer Tools](/learn/react-developer-tools), and use devices similar to the ones your app's users have in order to get a realistic sense of what's actually slowing down your app. + +</Recipes> + +--- + +### Updating state from a memoized callback + +Sometimes, you might need to update state based on previous state from a memoized callback. + +This `handleAddTodo` function specifies `todos` as a dependency because it computes the next todos from it: + +```js +function TodoList() { + const [todos, setTodos] = useState([]); + + const handleAddTodo = useCallback((text) => { + const newTodo = { id: nextId++, text }; + setTodos([...todos, newTodo]); + }, [todos]); + // ... +``` + +You'll usually want memoized functions to have as few dependencies as possible. When you read some state only to calculate the next state, you can remove that dependency by passing an [updater function](/reference/react/useState#updating-state-based-on-the-previous-state) instead: + +```js +function TodoList() { + const [todos, setTodos] = useState([]); + + const handleAddTodo = useCallback((text) => { + const newTodo = { id: nextId++, text }; + setTodos(todos => [...todos, newTodo]); + }, []); // ✅ No need for the todos dependency + // ... +``` + +Here, instead of making `todos` a dependency and reading it inside, you pass an instruction about _how_ to update the state (`todos => [...todos, newTodo]`) to React. [Read more about updater functions.](/reference/react/useState#updating-state-based-on-the-previous-state) + +--- + +### Preventing an Effect from firing too often + +Sometimes, you might want to call a function from inside an [Effect:](/learn/synchronizing-with-effects) + +```js +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + function createOptions() { + return { + serverUrl: 'https://localhost:1234', + roomId: roomId + }; + } + + useEffect(() => { + const options = createOptions(); + const connection = createConnection(); + connection.connect(); + // ... +``` + +This creates a problem. [Every reactive value must be declared as a dependency of your Effect.](/learn/lifecycle-of-reactive-effects#react-verifies-that-you-specified-every-reactive-value-as-a-dependency) However, if you declare `createOptions` as a dependency, it will cause your Effect to constantly reconnect to the chat room: + +```js +useEffect(() => { + const options = createOptions(); + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); +}, [createOptions]); // 🔴 Problem: This dependency changes on every render +// ... +``` + +To solve this, you can wrap the function you need to call from an Effect into `useCallback`: + +```js +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + const createOptions = useCallback(() => { + return { + serverUrl: 'https://localhost:1234', + roomId: roomId + }; + }, [roomId]); // ✅ Only changes when roomId changes + + useEffect(() => { + const options = createOptions(); + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); + }, [createOptions]); // ✅ Only changes when createOptions changes + // ... +``` + +This ensures that the `createOptions` function is the same between re-renders if the `roomId` is the same. **However, it's even better to remove the need for a function dependency.** Move your function _inside_ the Effect: + +```js +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + useEffect(() => { + function createOptions() { // ✅ No need for useCallback or function dependencies! + return { + serverUrl: 'https://localhost:1234', + roomId: roomId + }; + } + + const options = createOptions(); + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ Only changes when roomId changes + // ... +``` + +Now your code is simpler and doesn't need `useCallback`. [Learn more about removing Effect dependencies.](/learn/removing-effect-dependencies#move-dynamic-objects-and-functions-inside-your-effect) + +--- + +### Optimizing a custom Hook + +If you're writing a [custom Hook,](/learn/reusing-logic-with-custom-hooks) it's recommended to wrap any functions that it returns into `useCallback`: + +```js +function useRouter() { + const { dispatch } = useContext(RouterStateContext); + + const navigate = useCallback( + (url) => { + dispatch({ type: "navigate", url }); + }, + [dispatch] + ); + + const goBack = useCallback(() => { + dispatch({ type: "back" }); + }, [dispatch]); + + return { + navigate, + goBack, + }; +} +``` + +This ensures that the consumers of your Hook can optimize their own code when needed. + +--- + +## Troubleshooting + +### Every time my component renders, `useCallback` returns a different function + +Make sure you've specified the dependency array as a second argument! + +If you forget the dependency array, `useCallback` will return a new function every time: + +```js +function ProductPage({ productId, referrer }) { + const handleSubmit = useCallback((orderDetails) => { + post('/product/' + productId + '/buy', { + referrer, + orderDetails, + }); + }); // 🔴 Returns a new function every time: no dependency array + // ... +``` + +This is the corrected version passing the dependency array as a second argument: + +```js +function ProductPage({ productId, referrer }) { + const handleSubmit = useCallback((orderDetails) => { + post('/product/' + productId + '/buy', { + referrer, + orderDetails, + }); + }, [productId, referrer]); // ✅ Does not return a new function unnecessarily + // ... +``` + +If this doesn't help, then the problem is that at least one of your dependencies is different from the previous render. You can debug this problem by manually logging your dependencies to the console: + +```js +const handleSubmit = useCallback( + (orderDetails) => { + // .. + }, + [productId, referrer] +); + +console.log([productId, referrer]); +``` + +You can then right-click on the arrays from different re-renders in the console and select "Store as a global variable" for both of them. Assuming the first one got saved as `temp1` and the second one got saved as `temp2`, you can then use the browser console to check whether each dependency in both arrays is the same: + +```js +Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays? +Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays? +Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ... +``` + +When you find which dependency is breaking memoization, either find a way to remove it, or [memoize it as well.](/reference/react/useMemo#memoizing-a-dependency-of-another-hook) + +--- + +### I need to call `useCallback` for each list item in a loop, but it's not allowed + +Suppose the `Chart` component is wrapped in [`memo`](/reference/react/memo). You want to skip re-rendering every `Chart` in the list when the `ReportList` component re-renders. However, you can't call `useCallback` in a loop: + +```js +function ReportList({ items }) { + return ( + <article> + {items.map((item) => { + // 🔴 You can't call useCallback in a loop like this: + const handleClick = useCallback(() => { + sendReport(item); + }, [item]); + + return ( + <figure key={item.id}> + <Chart onClick={handleClick} /> + </figure> + ); + })} + </article> + ); +} +``` + +Instead, extract a component for an individual item, and put `useCallback` there: + +```js +function ReportList({ items }) { + return ( + <article> + {items.map((item) => ( + <Report key={item.id} item={item} /> + ))} + </article> + ); +} + +function Report({ item }) { + // ✅ Call useCallback at the top level: + const handleClick = useCallback(() => { + sendReport(item); + }, [item]); + + return ( + <figure> + <Chart onClick={handleClick} /> + </figure> + ); +} +``` + +Alternatively, you could remove `useCallback` in the last snippet and instead wrap `Report` itself in [`memo`.](/reference/react/memo) If the `item` prop does not change, `Report` will skip re-rendering, so `Chart` will skip re-rendering too: + +```js +function ReportList({ items }) { + // ... +} + +const Report = memo(function Report({ item }) { + function handleClick() { + sendReport(item); + } + + return ( + <figure> + <Chart onClick={handleClick} /> + </figure> + ); +}); +``` diff --git a/docs/src/reference/use-connection.md b/docs/src/reference/use-connection.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/use-context.md b/docs/src/reference/use-context.md new file mode 100644 index 000000000..02c4294d1 --- /dev/null +++ b/docs/src/reference/use-context.md @@ -0,0 +1,1321 @@ +## Overview + +<p class="intro" markdown> + +`useContext` is a React Hook that lets you read and subscribe to [context](/learn/passing-data-deeply-with-context) from your component. + +```js +const value = useContext(SomeContext); +``` + +</p> + +--- + +## Reference + +### `useContext(SomeContext)` + +Call `useContext` at the top level of your component to read and subscribe to [context.](/learn/passing-data-deeply-with-context) + +```js +import { useContext } from 'react'; + +function MyComponent() { + const theme = useContext(ThemeContext); + // ... +``` + +[See more examples below.](#usage) + +#### Parameters + +- `SomeContext`: The context that you've previously created with [`createContext`](/reference/react/createContext). The context itself does not hold the information, it only represents the kind of information you can provide or read from components. + +#### Returns + +`useContext` returns the context value for the calling component. It is determined as the `value` passed to the closest `SomeContext.Provider` above the calling component in the tree. If there is no such provider, then the returned value will be the `defaultValue` you have passed to [`createContext`](/reference/react/createContext) for that context. The returned value is always up-to-date. React automatically re-renders components that read some context if it changes. + +#### Caveats + +- `useContext()` call in a component is not affected by providers returned from the _same_ component. The corresponding `<Context.Provider>` **needs to be _above_** the component doing the `useContext()` call. +- React **automatically re-renders** all the children that use a particular context starting from the provider that receives a different `value`. The previous and the next values are compared with the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. Skipping re-renders with [`memo`](/reference/react/memo) does not prevent the children receiving fresh context values. +- If your build system produces duplicates modules in the output (which can happen with symlinks), this can break context. Passing something via context only works if `SomeContext` that you use to provide context and `SomeContext` that you use to read it are **_exactly_ the same object**, as determined by a `===` comparison. + +--- + +## Usage + +### Passing data deeply into the tree + +Call `useContext` at the top level of your component to read and subscribe to [context.](/learn/passing-data-deeply-with-context) + +```js +import { useContext } from 'react'; + +function Button() { + const theme = useContext(ThemeContext); + // ... +``` + +`useContext` returns the <CodeStep step={2}>context value</CodeStep> for the <CodeStep step={1}>context</CodeStep> you passed. To determine the context value, React searches the component tree and finds **the closest context provider above** for that particular context. + +To pass context to a `Button`, wrap it or one of its parent components into the corresponding context provider: + +```js +function MyPage() { + return ( + <ThemeContext.Provider value="dark"> + <Form /> + </ThemeContext.Provider> + ); +} + +function Form() { + // ... renders buttons inside ... +} +``` + +It doesn't matter how many layers of components there are between the provider and the `Button`. When a `Button` _anywhere_ inside of `Form` calls `useContext(ThemeContext)`, it will receive `"dark"` as the value. + +<Pitfall> + +`useContext()` always looks for the closest provider _above_ the component that calls it. It searches upwards and **does not** consider providers in the component from which you're calling `useContext()`. + +</Pitfall> + +```js +import { createContext, useContext } from "react"; + +const ThemeContext = createContext(null); + +export default function MyApp() { + return ( + <ThemeContext.Provider value="dark"> + <Form /> + </ThemeContext.Provider> + ); +} + +function Form() { + return ( + <Panel title="Welcome"> + <Button>Sign up</Button> + <Button>Log in</Button> + </Panel> + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); + const className = "panel-" + theme; + return ( + <section className={className}> + <h1>{title}</h1> + {children} + </section> + ); +} + +function Button({ children }) { + const theme = useContext(ThemeContext); + const className = "button-" + theme; + return <button className={className}>{children}</button>; +} +``` + +```css +.panel-light, +.panel-dark { + border: 1px solid black; + border-radius: 4px; + padding: 20px; +} +.panel-light { + color: #222; + background: #fff; +} + +.panel-dark { + color: #fff; + background: rgb(23, 32, 42); +} + +.button-light, +.button-dark { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} + +.button-dark { + background: #222; + color: #fff; +} + +.button-light { + background: #fff; + color: #222; +} +``` + +--- + +### Updating data passed via context + +Often, you'll want the context to change over time. To update context, combine it with [state.](/reference/react/useState) Declare a state variable in the parent component, and pass the current state down as the <CodeStep step={2}>context value</CodeStep> to the provider. + +```js +function MyPage() { + const [theme, setTheme] = useState("dark"); + return ( + <ThemeContext.Provider value={theme}> + <Form /> + <Button + onClick={() => { + setTheme("light"); + }} + > + Switch to light theme + </Button> + </ThemeContext.Provider> + ); +} +``` + +Now any `Button` inside of the provider will receive the current `theme` value. If you call `setTheme` to update the `theme` value that you pass to the provider, all `Button` components will re-render with the new `'light'` value. + +<Recipes titleText="Examples of updating context" titleId="examples-basic"> + +#### Updating a value via context + +In this example, the `MyApp` component holds a state variable which is then passed to the `ThemeContext` provider. Checking the "Dark mode" checkbox updates the state. Changing the provided value re-renders all the components using that context. + +```js +import { createContext, useContext, useState } from "react"; + +const ThemeContext = createContext(null); + +export default function MyApp() { + const [theme, setTheme] = useState("light"); + return ( + <ThemeContext.Provider value={theme}> + <Form /> + <label> + <input + type="checkbox" + checked={theme === "dark"} + onChange={(e) => { + setTheme(e.target.checked ? "dark" : "light"); + }} + /> + Use dark mode + </label> + </ThemeContext.Provider> + ); +} + +function Form({ children }) { + return ( + <Panel title="Welcome"> + <Button>Sign up</Button> + <Button>Log in</Button> + </Panel> + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); + const className = "panel-" + theme; + return ( + <section className={className}> + <h1>{title}</h1> + {children} + </section> + ); +} + +function Button({ children }) { + const theme = useContext(ThemeContext); + const className = "button-" + theme; + return <button className={className}>{children}</button>; +} +``` + +```css +.panel-light, +.panel-dark { + border: 1px solid black; + border-radius: 4px; + padding: 20px; + margin-bottom: 10px; +} +.panel-light { + color: #222; + background: #fff; +} + +.panel-dark { + color: #fff; + background: rgb(23, 32, 42); +} + +.button-light, +.button-dark { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} + +.button-dark { + background: #222; + color: #fff; +} + +.button-light { + background: #fff; + color: #222; +} +``` + +Note that `value="dark"` passes the `"dark"` string, but `value={theme}` passes the value of the JavaScript `theme` variable with [JSX curly braces.](/learn/javascript-in-jsx-with-curly-braces) Curly braces also let you pass context values that aren't strings. + +#### Updating an object via context + +In this example, there is a `currentUser` state variable which holds an object. You combine `{ currentUser, setCurrentUser }` into a single object and pass it down through the context inside the `value={}`. This lets any component below, such as `LoginButton`, read both `currentUser` and `setCurrentUser`, and then call `setCurrentUser` when needed. + +```js +import { createContext, useContext, useState } from "react"; + +const CurrentUserContext = createContext(null); + +export default function MyApp() { + const [currentUser, setCurrentUser] = useState(null); + return ( + <CurrentUserContext.Provider + value={{ + currentUser, + setCurrentUser, + }} + > + <Form /> + </CurrentUserContext.Provider> + ); +} + +function Form({ children }) { + return ( + <Panel title="Welcome"> + <LoginButton /> + </Panel> + ); +} + +function LoginButton() { + const { currentUser, setCurrentUser } = useContext(CurrentUserContext); + + if (currentUser !== null) { + return <p>You logged in as {currentUser.name}.</p>; + } + + return ( + <Button + onClick={() => { + setCurrentUser({ name: "Advika" }); + }} + > + Log in as Advika + </Button> + ); +} + +function Panel({ title, children }) { + return ( + <section className="panel"> + <h1>{title}</h1> + {children} + </section> + ); +} + +function Button({ children, onClick }) { + return ( + <button className="button" onClick={onClick}> + {children} + </button> + ); +} +``` + +```css +label { + display: block; +} + +.panel { + border: 1px solid black; + border-radius: 4px; + padding: 20px; + margin-bottom: 10px; +} + +.button { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} +``` + +#### Multiple contexts + +In this example, there are two independent contexts. `ThemeContext` provides the current theme, which is a string, while `CurrentUserContext` holds the object representing the current user. + +```js +import { createContext, useContext, useState } from "react"; + +const ThemeContext = createContext(null); +const CurrentUserContext = createContext(null); + +export default function MyApp() { + const [theme, setTheme] = useState("light"); + const [currentUser, setCurrentUser] = useState(null); + return ( + <ThemeContext.Provider value={theme}> + <CurrentUserContext.Provider + value={{ + currentUser, + setCurrentUser, + }} + > + <WelcomePanel /> + <label> + <input + type="checkbox" + checked={theme === "dark"} + onChange={(e) => { + setTheme(e.target.checked ? "dark" : "light"); + }} + /> + Use dark mode + </label> + </CurrentUserContext.Provider> + </ThemeContext.Provider> + ); +} + +function WelcomePanel({ children }) { + const { currentUser } = useContext(CurrentUserContext); + return ( + <Panel title="Welcome"> + {currentUser !== null ? <Greeting /> : <LoginForm />} + </Panel> + ); +} + +function Greeting() { + const { currentUser } = useContext(CurrentUserContext); + return <p>You logged in as {currentUser.name}.</p>; +} + +function LoginForm() { + const { setCurrentUser } = useContext(CurrentUserContext); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const canLogin = firstName !== "" && lastName !== ""; + return ( + <> + <label> + First name{": "} + <input + required + value={firstName} + onChange={(e) => setFirstName(e.target.value)} + /> + </label> + <label> + Last name{": "} + <input + required + value={lastName} + onChange={(e) => setLastName(e.target.value)} + /> + </label> + <Button + disabled={!canLogin} + onClick={() => { + setCurrentUser({ + name: firstName + " " + lastName, + }); + }} + > + Log in + </Button> + {!canLogin && <i>Fill in both fields.</i>} + </> + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); + const className = "panel-" + theme; + return ( + <section className={className}> + <h1>{title}</h1> + {children} + </section> + ); +} + +function Button({ children, disabled, onClick }) { + const theme = useContext(ThemeContext); + const className = "button-" + theme; + return ( + <button className={className} disabled={disabled} onClick={onClick}> + {children} + </button> + ); +} +``` + +```css +label { + display: block; +} + +.panel-light, +.panel-dark { + border: 1px solid black; + border-radius: 4px; + padding: 20px; + margin-bottom: 10px; +} +.panel-light { + color: #222; + background: #fff; +} + +.panel-dark { + color: #fff; + background: rgb(23, 32, 42); +} + +.button-light, +.button-dark { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} + +.button-dark { + background: #222; + color: #fff; +} + +.button-light { + background: #fff; + color: #222; +} +``` + +#### Extracting providers to a component + +As your app grows, it is expected that you'll have a "pyramid" of contexts closer to the root of your app. There is nothing wrong with that. However, if you dislike the nesting aesthetically, you can extract the providers into a single component. In this example, `MyProviders` hides the "plumbing" and renders the children passed to it inside the necessary providers. Note that the `theme` and `setTheme` state is needed in `MyApp` itself, so `MyApp` still owns that piece of the state. + +```js +import { createContext, useContext, useState } from "react"; + +const ThemeContext = createContext(null); +const CurrentUserContext = createContext(null); + +export default function MyApp() { + const [theme, setTheme] = useState("light"); + return ( + <MyProviders theme={theme} setTheme={setTheme}> + <WelcomePanel /> + <label> + <input + type="checkbox" + checked={theme === "dark"} + onChange={(e) => { + setTheme(e.target.checked ? "dark" : "light"); + }} + /> + Use dark mode + </label> + </MyProviders> + ); +} + +function MyProviders({ children, theme, setTheme }) { + const [currentUser, setCurrentUser] = useState(null); + return ( + <ThemeContext.Provider value={theme}> + <CurrentUserContext.Provider + value={{ + currentUser, + setCurrentUser, + }} + > + {children} + </CurrentUserContext.Provider> + </ThemeContext.Provider> + ); +} + +function WelcomePanel({ children }) { + const { currentUser } = useContext(CurrentUserContext); + return ( + <Panel title="Welcome"> + {currentUser !== null ? <Greeting /> : <LoginForm />} + </Panel> + ); +} + +function Greeting() { + const { currentUser } = useContext(CurrentUserContext); + return <p>You logged in as {currentUser.name}.</p>; +} + +function LoginForm() { + const { setCurrentUser } = useContext(CurrentUserContext); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const canLogin = firstName !== "" && lastName !== ""; + return ( + <> + <label> + First name{": "} + <input + required + value={firstName} + onChange={(e) => setFirstName(e.target.value)} + /> + </label> + <label> + Last name{": "} + <input + required + value={lastName} + onChange={(e) => setLastName(e.target.value)} + /> + </label> + <Button + disabled={!canLogin} + onClick={() => { + setCurrentUser({ + name: firstName + " " + lastName, + }); + }} + > + Log in + </Button> + {!canLogin && <i>Fill in both fields.</i>} + </> + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); + const className = "panel-" + theme; + return ( + <section className={className}> + <h1>{title}</h1> + {children} + </section> + ); +} + +function Button({ children, disabled, onClick }) { + const theme = useContext(ThemeContext); + const className = "button-" + theme; + return ( + <button className={className} disabled={disabled} onClick={onClick}> + {children} + </button> + ); +} +``` + +```css +label { + display: block; +} + +.panel-light, +.panel-dark { + border: 1px solid black; + border-radius: 4px; + padding: 20px; + margin-bottom: 10px; +} +.panel-light { + color: #222; + background: #fff; +} + +.panel-dark { + color: #fff; + background: rgb(23, 32, 42); +} + +.button-light, +.button-dark { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} + +.button-dark { + background: #222; + color: #fff; +} + +.button-light { + background: #fff; + color: #222; +} +``` + +#### Scaling up with context and a reducer + +In larger apps, it is common to combine context with a [reducer](/reference/react/useReducer) to extract the logic related to some state out of components. In this example, all the "wiring" is hidden in the `TasksContext.js`, which contains a reducer and two separate contexts. + +Read a [full walkthrough](/learn/scaling-up-with-reducer-and-context) of this example. + +```js +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; +import { TasksProvider } from "./TasksContext.js"; + +export default function TaskApp() { + return ( + <TasksProvider> + <h1>Day off in Kyoto</h1> + <AddTask /> + <TaskList /> + </TasksProvider> + ); +} +``` + +```js +import { createContext, useContext, useReducer } from "react"; + +const TasksContext = createContext(null); + +const TasksDispatchContext = createContext(null); + +export function TasksProvider({ children }) { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + return ( + <TasksContext.Provider value={tasks}> + <TasksDispatchContext.Provider value={dispatch}> + {children} + </TasksDispatchContext.Provider> + </TasksContext.Provider> + ); +} + +export function useTasks() { + return useContext(TasksContext); +} + +export function useTasksDispatch() { + return useContext(TasksDispatchContext); +} + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +const initialTasks = [ + { id: 0, text: "Philosopher’s Path", done: true }, + { id: 1, text: "Visit the temple", done: false }, + { id: 2, text: "Drink matcha", done: false }, +]; +``` + +```js +import { useState, useContext } from "react"; +import { useTasksDispatch } from "./TasksContext.js"; + +export default function AddTask() { + const [text, setText] = useState(""); + const dispatch = useTasksDispatch(); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + onClick={() => { + setText(""); + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + }} + > + Add + </button> + </> + ); +} + +let nextId = 3; +``` + +```js +import { useState, useContext } from "react"; +import { useTasks, useTasksDispatch } from "./TasksContext.js"; + +export default function TaskList() { + const tasks = useTasks(); + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task task={task} /> + </li> + ))} + </ul> + ); +} + +function Task({ task }) { + const [isEditing, setIsEditing] = useState(false); + const dispatch = useTasksDispatch(); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + text: e.target.value, + }, + }); + }} + /> + <button onClick={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button onClick={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + dispatch({ + type: "changed", + task: { + ...task, + done: e.target.checked, + }, + }); + }} + /> + {taskContent} + <button + onClick={() => { + dispatch({ + type: "deleted", + id: task.id, + }); + }} + > + Delete + </button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +</Recipes> + +--- + +### Specifying a fallback default value + +If React can't find any providers of that particular <CodeStep step={1}>context</CodeStep> in the parent tree, the context value returned by `useContext()` will be equal to the <CodeStep step={3}>default value</CodeStep> that you specified when you [created that context](/reference/react/createContext): + +```js +const ThemeContext = createContext(null); +``` + +The default value **never changes**. If you want to update context, use it with state as [described above.](#updating-data-passed-via-context) + +Often, instead of `null`, there is some more meaningful value you can use as a default, for example: + +```js +const ThemeContext = createContext("light"); +``` + +This way, if you accidentally render some component without a corresponding provider, it won't break. This also helps your components work well in a test environment without setting up a lot of providers in the tests. + +In the example below, the "Toggle theme" button is always light because it's **outside any theme context provider** and the default context theme value is `'light'`. Try editing the default theme to be `'dark'`. + +```js +import { createContext, useContext, useState } from "react"; + +const ThemeContext = createContext("light"); + +export default function MyApp() { + const [theme, setTheme] = useState("light"); + return ( + <> + <ThemeContext.Provider value={theme}> + <Form /> + </ThemeContext.Provider> + <Button + onClick={() => { + setTheme(theme === "dark" ? "light" : "dark"); + }} + > + Toggle theme + </Button> + </> + ); +} + +function Form({ children }) { + return ( + <Panel title="Welcome"> + <Button>Sign up</Button> + <Button>Log in</Button> + </Panel> + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); + const className = "panel-" + theme; + return ( + <section className={className}> + <h1>{title}</h1> + {children} + </section> + ); +} + +function Button({ children, onClick }) { + const theme = useContext(ThemeContext); + const className = "button-" + theme; + return ( + <button className={className} onClick={onClick}> + {children} + </button> + ); +} +``` + +```css +.panel-light, +.panel-dark { + border: 1px solid black; + border-radius: 4px; + padding: 20px; + margin-bottom: 10px; +} +.panel-light { + color: #222; + background: #fff; +} + +.panel-dark { + color: #fff; + background: rgb(23, 32, 42); +} + +.button-light, +.button-dark { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} + +.button-dark { + background: #222; + color: #fff; +} + +.button-light { + background: #fff; + color: #222; +} +``` + +--- + +### Overriding context for a part of the tree + +You can override the context for a part of the tree by wrapping that part in a provider with a different value. + +```js +<ThemeContext.Provider value="dark"> + ... + <ThemeContext.Provider value="light"> + <Footer /> + </ThemeContext.Provider> + ... +</ThemeContext.Provider> +``` + +You can nest and override providers as many times as you need. + +<Recipes title="Examples of overriding context"> + +#### Overriding a theme + +Here, the button _inside_ the `Footer` receives a different context value (`"light"`) than the buttons outside (`"dark"`). + +```js +import { createContext, useContext } from "react"; + +const ThemeContext = createContext(null); + +export default function MyApp() { + return ( + <ThemeContext.Provider value="dark"> + <Form /> + </ThemeContext.Provider> + ); +} + +function Form() { + return ( + <Panel title="Welcome"> + <Button>Sign up</Button> + <Button>Log in</Button> + <ThemeContext.Provider value="light"> + <Footer /> + </ThemeContext.Provider> + </Panel> + ); +} + +function Footer() { + return ( + <footer> + <Button>Settings</Button> + </footer> + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); + const className = "panel-" + theme; + return ( + <section className={className}> + {title && <h1>{title}</h1>} + {children} + </section> + ); +} + +function Button({ children }) { + const theme = useContext(ThemeContext); + const className = "button-" + theme; + return <button className={className}>{children}</button>; +} +``` + +```css +footer { + margin-top: 20px; + border-top: 1px solid #aaa; +} + +.panel-light, +.panel-dark { + border: 1px solid black; + border-radius: 4px; + padding: 20px; +} +.panel-light { + color: #222; + background: #fff; +} + +.panel-dark { + color: #fff; + background: rgb(23, 32, 42); +} + +.button-light, +.button-dark { + border: 1px solid #777; + padding: 5px; + margin-right: 10px; + margin-top: 10px; +} + +.button-dark { + background: #222; + color: #fff; +} + +.button-light { + background: #fff; + color: #222; +} +``` + +#### Automatically nested headings + +You can "accumulate" information when you nest context providers. In this example, the `Section` component keeps track of the `LevelContext` which specifies the depth of the section nesting. It reads the `LevelContext` from the parent section, and provides the `LevelContext` number increased by one to its children. As a result, the `Heading` component can automatically decide which of the `<h1>`, `<h2>`, `<h3>`, ..., tags to use based on how many `Section` components it is nested inside of. + +Read a [detailed walkthrough](/learn/passing-data-deeply-with-context) of this example. + +```js +import Heading from "./Heading.js"; +import Section from "./Section.js"; + +export default function Page() { + return ( + <Section> + <Heading>Title</Heading> + <Section> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Heading>Heading</Heading> + <Section> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Heading>Sub-heading</Heading> + <Section> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + <Heading>Sub-sub-heading</Heading> + </Section> + </Section> + </Section> + </Section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Section({ children }) { + const level = useContext(LevelContext); + return ( + <section className="section"> + <LevelContext.Provider value={level + 1}> + {children} + </LevelContext.Provider> + </section> + ); +} +``` + +```js +import { useContext } from "react"; +import { LevelContext } from "./LevelContext.js"; + +export default function Heading({ children }) { + const level = useContext(LevelContext); + switch (level) { + case 0: + throw Error("Heading must be inside a Section!"); + case 1: + return <h1>{children}</h1>; + case 2: + return <h2>{children}</h2>; + case 3: + return <h3>{children}</h3>; + case 4: + return <h4>{children}</h4>; + case 5: + return <h5>{children}</h5>; + case 6: + return <h6>{children}</h6>; + default: + throw Error("Unknown level: " + level); + } +} +``` + +```js +import { createContext } from "react"; + +export const LevelContext = createContext(0); +``` + +```css +.section { + padding: 10px; + margin: 5px; + border-radius: 5px; + border: 1px solid #aaa; +} +``` + +</Recipes> + +--- + +### Optimizing re-renders when passing objects and functions + +You can pass any values via context, including objects and functions. + +```js +function MyApp() { + const [currentUser, setCurrentUser] = useState(null); + + function login(response) { + storeCredentials(response.credentials); + setCurrentUser(response.user); + } + + return ( + <AuthContext.Provider value={{ currentUser, login }}> + <Page /> + </AuthContext.Provider> + ); +} +``` + +Here, the <CodeStep step={2}>context value</CodeStep> is a JavaScript object with two properties, one of which is a function. Whenever `MyApp` re-renders (for example, on a route update), this will be a _different_ object pointing at a _different_ function, so React will also have to re-render all components deep in the tree that call `useContext(AuthContext)`. + +In smaller apps, this is not a problem. However, there is no need to re-render them if the underlying data, like `currentUser`, has not changed. To help React take advantage of that fact, you may wrap the `login` function with [`useCallback`](/reference/react/useCallback) and wrap the object creation into [`useMemo`](/reference/react/useMemo). This is a performance optimization: + +```js +import { useCallback, useMemo } from "react"; + +function MyApp() { + const [currentUser, setCurrentUser] = useState(null); + + const login = useCallback((response) => { + storeCredentials(response.credentials); + setCurrentUser(response.user); + }, []); + + const contextValue = useMemo( + () => ({ + currentUser, + login, + }), + [currentUser, login] + ); + + return ( + <AuthContext.Provider value={contextValue}> + <Page /> + </AuthContext.Provider> + ); +} +``` + +As a result of this change, even if `MyApp` needs to re-render, the components calling `useContext(AuthContext)` won't need to re-render unless `currentUser` has changed. + +Read more about [`useMemo`](/reference/react/useMemo#skipping-re-rendering-of-components) and [`useCallback`.](/reference/react/useCallback#skipping-re-rendering-of-components) + +--- + +## Troubleshooting + +### My component doesn't see the value from my provider + +There are a few common ways that this can happen: + +1. You're rendering `<SomeContext.Provider>` in the same component (or below) as where you're calling `useContext()`. Move `<SomeContext.Provider>` _above and outside_ the component calling `useContext()`. +2. You may have forgotten to wrap your component with `<SomeContext.Provider>`, or you might have put it in a different part of the tree than you thought. Check whether the hierarchy is right using [React DevTools.](/learn/react-developer-tools) +3. You might be running into some build issue with your tooling that causes `SomeContext` as seen from the providing component and `SomeContext` as seen by the reading component to be two different objects. This can happen if you use symlinks, for example. You can verify this by assigning them to globals like `window.SomeContext1` and `window.SomeContext2` and then checking whether `window.SomeContext1 === window.SomeContext2` in the console. If they're not the same, fix that issue on the build tool level. + +### I am always getting `undefined` from my context although the default value is different + +You might have a provider without a `value` in the tree: + +```js +// 🚩 Doesn't work: no value prop +<ThemeContext.Provider> + <Button /> +</ThemeContext.Provider> +``` + +If you forget to specify `value`, it's like passing `value={undefined}`. + +You may have also mistakingly used a different prop name by mistake: + +```js +// 🚩 Doesn't work: prop should be called "value" +<ThemeContext.Provider theme={theme}> + <Button /> +</ThemeContext.Provider> +``` + +In both of these cases you should see a warning from React in the console. To fix them, call the prop `value`: + +```js +// ✅ Passing the value prop +<ThemeContext.Provider value={theme}> + <Button /> +</ThemeContext.Provider> +``` + +Note that the [default value from your `createContext(defaultValue)` call](#specifying-a-fallback-default-value) is only used **if there is no matching provider above at all.** If there is a `<SomeContext.Provider value={undefined}>` component somewhere in the parent tree, the component calling `useContext(SomeContext)` _will_ receive `undefined` as the context value. diff --git a/docs/src/reference/use-debug-value.md b/docs/src/reference/use-debug-value.md new file mode 100644 index 000000000..37adcc537 --- /dev/null +++ b/docs/src/reference/use-debug-value.md @@ -0,0 +1,118 @@ +## Overview + +<p class="intro" markdown> + +`useDebugValue` is a React Hook that lets you add a label to a custom Hook in [React DevTools.](/learn/react-developer-tools) + +```js +useDebugValue(value, format?) +``` + +</p> + +--- + +## Reference + +### `useDebugValue(value, format?)` + +Call `useDebugValue` at the top level of your [custom Hook](/learn/reusing-logic-with-custom-hooks) to display a readable debug value: + +```js +import { useDebugValue } from "react"; + +function useOnlineStatus() { + // ... + useDebugValue(isOnline ? "Online" : "Offline"); + // ... +} +``` + +[See more examples below.](#usage) + +#### Parameters + +- `value`: The value you want to display in React DevTools. It can have any type. +- **optional** `format`: A formatting function. When the component is inspected, React DevTools will call the formatting function with the `value` as the argument, and then display the returned formatted value (which may have any type). If you don't specify the formatting function, the original `value` itself will be displayed. + +#### Returns + +`useDebugValue` does not return anything. + +## Usage + +### Adding a label to a custom Hook + +Call `useDebugValue` at the top level of your [custom Hook](/learn/reusing-logic-with-custom-hooks) to display a readable <CodeStep step={1}>debug value</CodeStep> for [React DevTools.](/learn/react-developer-tools) + +```js +import { useDebugValue } from "react"; + +function useOnlineStatus() { + // ... + useDebugValue(isOnline ? "Online" : "Offline"); + // ... +} +``` + +This gives components calling `useOnlineStatus` a label like `OnlineStatus: "Online"` when you inspect them: + + + +Without the `useDebugValue` call, only the underlying data (in this example, `true`) would be displayed. + +```js +import { useOnlineStatus } from "./useOnlineStatus.js"; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +export default function App() { + return <StatusBar />; +} +``` + +```js +import { useSyncExternalStore, useDebugValue } from "react"; + +export function useOnlineStatus() { + const isOnline = useSyncExternalStore( + subscribe, + () => navigator.onLine, + () => true + ); + useDebugValue(isOnline ? "Online" : "Offline"); + return isOnline; +} + +function subscribe(callback) { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} +``` + +<Note> + +Don't add debug values to every custom Hook. It's most valuable for custom Hooks that are part of shared libraries and that have a complex internal data structure that's difficult to inspect. + +</Note> + +--- + +### Deferring formatting of a debug value + +You can also pass a formatting function as the second argument to `useDebugValue`: + +```js +useDebugValue(date, (date) => date.toDateString()); +``` + +Your formatting function will receive the <CodeStep step={1}>debug value</CodeStep> as a parameter and should return a <CodeStep step={2}>formatted display value</CodeStep>. When your component is inspected, React DevTools will call this function and display its result. + +This lets you avoid running potentially expensive formatting logic unless the component is actually inspected. For example, if `date` is a Date value, this avoids calling `toDateString()` on it for every render. diff --git a/docs/src/reference/use-deferred-value.md b/docs/src/reference/use-deferred-value.md new file mode 100644 index 000000000..7110f7ddd --- /dev/null +++ b/docs/src/reference/use-deferred-value.md @@ -0,0 +1,995 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. +<!-- +## Overview + +<p class="intro" markdown> + +`useDeferredValue` is a React Hook that lets you defer updating a part of the UI. + +```js +const deferredValue = useDeferredValue(value); +``` + +</p> + +--- + +## Reference + +### `useDeferredValue(value)` + +Call `useDeferredValue` at the top level of your component to get a deferred version of that value. + +```js +import { useState, useDeferredValue } from "react"; + +function SearchPage() { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + // ... +} +``` + +[See more examples below.](#usage) + +#### Parameters + +- `value`: The value you want to defer. It can have any type. + +#### Returns + +During the initial render, the returned deferred value will be the same as the value you provided. During updates, React will first attempt a re-render with the old value (so it will return the old value), and then try another re-render in background with the new value (so it will return the updated value). + +#### Caveats + +- The values you pass to `useDeferredValue` should either be primitive values (like strings and numbers) or objects created outside of rendering. If you create a new object during rendering and immediately pass it to `useDeferredValue`, it will be different on every render, causing unnecessary background re-renders. + +- When `useDeferredValue` receives a different value (compared with [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)), in addition to the current render (when it still uses the previous value), it schedules a re-render in the background with the new value. The background re-render is interruptible: if there's another update to the `value`, React will restart the background re-render from scratch. For example, if the user is typing into an input faster than a chart receiving its deferred value can re-render, the chart will only re-render after the user stops typing. + +- `useDeferredValue` is integrated with [`<Suspense>`.](/reference/react/Suspense) If the background update caused by a new value suspends the UI, the user will not see the fallback. They will see the old deferred value until the data loads. + +- `useDeferredValue` does not by itself prevent extra network requests. + +- There is no fixed delay caused by `useDeferredValue` itself. As soon as React finishes the original re-render, React will immediately start working on the background re-render with the new deferred value. Any updates caused by events (like typing) will interrupt the background re-render and get prioritized over it. + +- The background re-render caused by `useDeferredValue` does not fire Effects until it's committed to the screen. If the background re-render suspends, its Effects will run after the data loads and the UI updates. + +--- + +## Usage + +### Showing stale content while fresh content is loading + +Call `useDeferredValue` at the top level of your component to defer updating some part of your UI. + +```js +import { useState, useDeferredValue } from "react"; + +function SearchPage() { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + // ... +} +``` + +During the initial render, the <CodeStep step={2}>deferred value</CodeStep> will be the same as the <CodeStep step={1}>value</CodeStep> you provided. + +During updates, the <CodeStep step={2}>deferred value</CodeStep> will "lag behind" the latest <CodeStep step={1}>value</CodeStep>. In particular, React will first re-render _without_ updating the deferred value, and then try to re-render with the newly received value in background. + +**Let's walk through an example to see when this is useful.** + +<Note> + +This example assumes you use one of Suspense-enabled data sources: + +- Data fetching with Suspense-enabled frameworks like [Relay](https://relay.dev/docs/guided-tour/rendering/loading-states/) and [Next.js](https://nextjs.org/docs/getting-started/react-essentials) +- Lazy-loading component code with [`lazy`](/reference/react/lazy) + +[Learn more about Suspense and its limitations.](/reference/react/Suspense) + +</Note> + +In this example, the `SearchResults` component [suspends](/reference/react/Suspense#displaying-a-fallback-while-content-is-loading) while fetching the search results. Try typing `"a"`, waiting for the results, and then editing it to `"ab"`. The results for `"a"` get replaced by the loading fallback. + +```json package.json hidden +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { Suspense, useState } from "react"; +import SearchResults from "./SearchResults.js"; + +export default function App() { + const [query, setQuery] = useState(""); + return ( + <> + <label> + Search albums: + <input + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + </label> + <Suspense fallback={<h2>Loading...</h2>}> + <SearchResults query={query} /> + </Suspense> + </> + ); +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +export default function SearchResults({ query }) { + if (query === "") { + return null; + } + const albums = use(fetchData(`/search?q=${query}`)); + if (albums.length === 0) { + return ( + <p> + No matches for <i>"{query}"</i> + </p> + ); + } + return ( + <ul> + {albums.map((album) => ( + <li key={album.id}> + {album.title} ({album.year}) + </li> + ))} + </ul> + ); +} + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +// Note: the way you would do data fetching depends on +// the framework that you use together with Suspense. +// Normally, the caching logic would be inside a framework. + +let cache = new Map(); + +export function fetchData(url) { + if (!cache.has(url)) { + cache.set(url, getData(url)); + } + return cache.get(url); +} + +async function getData(url) { + if (url.startsWith("/search?q=")) { + return await getSearchResults(url.slice("/search?q=".length)); + } else { + throw Error("Not implemented"); + } +} + +async function getSearchResults(query) { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + const allAlbums = [ + { + id: 13, + title: "Let It Be", + year: 1970, + }, + { + id: 12, + title: "Abbey Road", + year: 1969, + }, + { + id: 11, + title: "Yellow Submarine", + year: 1969, + }, + { + id: 10, + title: "The Beatles", + year: 1968, + }, + { + id: 9, + title: "Magical Mystery Tour", + year: 1967, + }, + { + id: 8, + title: "Sgt. Pepper's Lonely Hearts Club Band", + year: 1967, + }, + { + id: 7, + title: "Revolver", + year: 1966, + }, + { + id: 6, + title: "Rubber Soul", + year: 1965, + }, + { + id: 5, + title: "Help!", + year: 1965, + }, + { + id: 4, + title: "Beatles For Sale", + year: 1964, + }, + { + id: 3, + title: "A Hard Day's Night", + year: 1964, + }, + { + id: 2, + title: "With The Beatles", + year: 1963, + }, + { + id: 1, + title: "Please Please Me", + year: 1963, + }, + ]; + + const lowerQuery = query.trim().toLowerCase(); + return allAlbums.filter((album) => { + const lowerTitle = album.title.toLowerCase(); + return ( + lowerTitle.startsWith(lowerQuery) || + lowerTitle.indexOf(" " + lowerQuery) !== -1 + ); + }); +} +``` + +```css +input { + margin: 10px; +} +``` + +A common alternative UI pattern is to _defer_ updating the list of results and to keep showing the previous results until the new results are ready. Call `useDeferredValue` to pass a deferred version of the query down: + +```js +export default function App() { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + return ( + <> + <label> + Search albums: + <input + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + </label> + <Suspense fallback={<h2>Loading...</h2>}> + <SearchResults query={deferredQuery} /> + </Suspense> + </> + ); +} +``` + +The `query` will update immediately, so the input will display the new value. However, the `deferredQuery` will keep its previous value until the data has loaded, so `SearchResults` will show the stale results for a bit. + +Enter `"a"` in the example below, wait for the results to load, and then edit the input to `"ab"`. Notice how instead of the Suspense fallback, you now see the stale result list until the new results have loaded: + +```json package.json hidden +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { Suspense, useState, useDeferredValue } from "react"; +import SearchResults from "./SearchResults.js"; + +export default function App() { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + return ( + <> + <label> + Search albums: + <input + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + </label> + <Suspense fallback={<h2>Loading...</h2>}> + <SearchResults query={deferredQuery} /> + </Suspense> + </> + ); +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +export default function SearchResults({ query }) { + if (query === "") { + return null; + } + const albums = use(fetchData(`/search?q=${query}`)); + if (albums.length === 0) { + return ( + <p> + No matches for <i>"{query}"</i> + </p> + ); + } + return ( + <ul> + {albums.map((album) => ( + <li key={album.id}> + {album.title} ({album.year}) + </li> + ))} + </ul> + ); +} + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +// Note: the way you would do data fetching depends on +// the framework that you use together with Suspense. +// Normally, the caching logic would be inside a framework. + +let cache = new Map(); + +export function fetchData(url) { + if (!cache.has(url)) { + cache.set(url, getData(url)); + } + return cache.get(url); +} + +async function getData(url) { + if (url.startsWith("/search?q=")) { + return await getSearchResults(url.slice("/search?q=".length)); + } else { + throw Error("Not implemented"); + } +} + +async function getSearchResults(query) { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + const allAlbums = [ + { + id: 13, + title: "Let It Be", + year: 1970, + }, + { + id: 12, + title: "Abbey Road", + year: 1969, + }, + { + id: 11, + title: "Yellow Submarine", + year: 1969, + }, + { + id: 10, + title: "The Beatles", + year: 1968, + }, + { + id: 9, + title: "Magical Mystery Tour", + year: 1967, + }, + { + id: 8, + title: "Sgt. Pepper's Lonely Hearts Club Band", + year: 1967, + }, + { + id: 7, + title: "Revolver", + year: 1966, + }, + { + id: 6, + title: "Rubber Soul", + year: 1965, + }, + { + id: 5, + title: "Help!", + year: 1965, + }, + { + id: 4, + title: "Beatles For Sale", + year: 1964, + }, + { + id: 3, + title: "A Hard Day's Night", + year: 1964, + }, + { + id: 2, + title: "With The Beatles", + year: 1963, + }, + { + id: 1, + title: "Please Please Me", + year: 1963, + }, + ]; + + const lowerQuery = query.trim().toLowerCase(); + return allAlbums.filter((album) => { + const lowerTitle = album.title.toLowerCase(); + return ( + lowerTitle.startsWith(lowerQuery) || + lowerTitle.indexOf(" " + lowerQuery) !== -1 + ); + }); +} +``` + +```css +input { + margin: 10px; +} +``` + +<DeepDive> + +#### How does deferring a value work under the hood? + +You can think of it as happening in two steps: + +1. **First, React re-renders with the new `query` (`"ab"`) but with the old `deferredQuery` (still `"a")`.** The `deferredQuery` value, which you pass to the result list, is _deferred:_ it "lags behind" the `query` value. + +2. **In background, React tries to re-render with _both_ `query` and `deferredQuery` updated to `"ab"`.** If this re-render completes, React will show it on the screen. However, if it suspends (the results for `"ab"` have not loaded yet), React will abandon this rendering attempt, and retry this re-render again after the data has loaded. The user will keep seeing the stale deferred value until the data is ready. + +The deferred "background" rendering is interruptible. For example, if you type into the input again, React will abandon it and restart with the new value. React will always use the latest provided value. + +Note that there is still a network request per each keystroke. What's being deferred here is displaying results (until they're ready), not the network requests themselves. Even if the user continues typing, responses for each keystroke get cached, so pressing Backspace is instant and doesn't fetch again. + +</DeepDive> + +--- + +### Indicating that the content is stale + +In the example above, there is no indication that the result list for the latest query is still loading. This can be confusing to the user if the new results take a while to load. To make it more obvious to the user that the result list does not match the latest query, you can add a visual indication when the stale result list is displayed: + +```js +<div + style={{ + opacity: query !== deferredQuery ? 0.5 : 1, + }} +> + <SearchResults query={deferredQuery} /> +</div> +``` + +With this change, as soon as you start typing, the stale result list gets slightly dimmed until the new result list loads. You can also add a CSS transition to delay dimming so that it feels gradual, like in the example below: + +```json package.json hidden +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { Suspense, useState, useDeferredValue } from "react"; +import SearchResults from "./SearchResults.js"; + +export default function App() { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const isStale = query !== deferredQuery; + return ( + <> + <label> + Search albums: + <input + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + </label> + <Suspense fallback={<h2>Loading...</h2>}> + <div + style={{ + opacity: isStale ? 0.5 : 1, + transition: isStale + ? "opacity 0.2s 0.2s linear" + : "opacity 0s 0s linear", + }} + > + <SearchResults query={deferredQuery} /> + </div> + </Suspense> + </> + ); +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +export default function SearchResults({ query }) { + if (query === "") { + return null; + } + const albums = use(fetchData(`/search?q=${query}`)); + if (albums.length === 0) { + return ( + <p> + No matches for <i>"{query}"</i> + </p> + ); + } + return ( + <ul> + {albums.map((album) => ( + <li key={album.id}> + {album.title} ({album.year}) + </li> + ))} + </ul> + ); +} + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +// Note: the way you would do data fetching depends on +// the framework that you use together with Suspense. +// Normally, the caching logic would be inside a framework. + +let cache = new Map(); + +export function fetchData(url) { + if (!cache.has(url)) { + cache.set(url, getData(url)); + } + return cache.get(url); +} + +async function getData(url) { + if (url.startsWith("/search?q=")) { + return await getSearchResults(url.slice("/search?q=".length)); + } else { + throw Error("Not implemented"); + } +} + +async function getSearchResults(query) { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + const allAlbums = [ + { + id: 13, + title: "Let It Be", + year: 1970, + }, + { + id: 12, + title: "Abbey Road", + year: 1969, + }, + { + id: 11, + title: "Yellow Submarine", + year: 1969, + }, + { + id: 10, + title: "The Beatles", + year: 1968, + }, + { + id: 9, + title: "Magical Mystery Tour", + year: 1967, + }, + { + id: 8, + title: "Sgt. Pepper's Lonely Hearts Club Band", + year: 1967, + }, + { + id: 7, + title: "Revolver", + year: 1966, + }, + { + id: 6, + title: "Rubber Soul", + year: 1965, + }, + { + id: 5, + title: "Help!", + year: 1965, + }, + { + id: 4, + title: "Beatles For Sale", + year: 1964, + }, + { + id: 3, + title: "A Hard Day's Night", + year: 1964, + }, + { + id: 2, + title: "With The Beatles", + year: 1963, + }, + { + id: 1, + title: "Please Please Me", + year: 1963, + }, + ]; + + const lowerQuery = query.trim().toLowerCase(); + return allAlbums.filter((album) => { + const lowerTitle = album.title.toLowerCase(); + return ( + lowerTitle.startsWith(lowerQuery) || + lowerTitle.indexOf(" " + lowerQuery) !== -1 + ); + }); +} +``` + +```css +input { + margin: 10px; +} +``` + +--- + +### Deferring re-rendering for a part of the UI + +You can also apply `useDeferredValue` as a performance optimization. It is useful when a part of your UI is slow to re-render, there's no easy way to optimize it, and you want to prevent it from blocking the rest of the UI. + +Imagine you have a text field and a component (like a chart or a long list) that re-renders on every keystroke: + +```js +function App() { + const [text, setText] = useState(""); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <SlowList text={text} /> + </> + ); +} +``` + +First, optimize `SlowList` to skip re-rendering when its props are the same. To do this, [wrap it in `memo`:](/reference/react/memo#skipping-re-rendering-when-props-are-unchanged) + +```js +const SlowList = memo(function SlowList({ text }) { + // ... +}); +``` + +However, this only helps if the `SlowList` props are _the same_ as during the previous render. The problem you're facing now is that it's slow when they're _different,_ and when you actually need to show different visual output. + +Concretely, the main performance problem is that whenever you type into the input, the `SlowList` receives new props, and re-rendering its entire tree makes the typing feel janky. In this case, `useDeferredValue` lets you prioritize updating the input (which must be fast) over updating the result list (which is allowed to be slower): + +```js +function App() { + const [text, setText] = useState(""); + const deferredText = useDeferredValue(text); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <SlowList text={deferredText} /> + </> + ); +} +``` + +This does not make re-rendering of the `SlowList` faster. However, it tells React that re-rendering the list can be deprioritized so that it doesn't block the keystrokes. The list will "lag behind" the input and then "catch up". Like before, React will attempt to update the list as soon as possible, but will not block the user from typing. + +<Recipes titleText="The difference between useDeferredValue and unoptimized re-rendering" titleId="examples"> + +#### Deferred re-rendering of the list + +In this example, each item in the `SlowList` component is **artificially slowed down** so that you can see how `useDeferredValue` lets you keep the input responsive. Type into the input and notice that typing feels snappy while the list "lags behind" it. + +```js +import { useState, useDeferredValue } from "react"; +import SlowList from "./SlowList.js"; + +export default function App() { + const [text, setText] = useState(""); + const deferredText = useDeferredValue(text); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <SlowList text={deferredText} /> + </> + ); +} +``` + +```js +import { memo } from "react"; + +const SlowList = memo(function SlowList({ text }) { + // Log once. The actual slowdown is inside SlowItem. + console.log("[ARTIFICIALLY SLOW] Rendering 250 <SlowItem />"); + + let items = []; + for (let i = 0; i < 250; i++) { + items.push(<SlowItem key={i} text={text} />); + } + return <ul className="items">{items}</ul>; +}); + +function SlowItem({ text }) { + let startTime = performance.now(); + while (performance.now() - startTime < 1) { + // Do nothing for 1 ms per item to emulate extremely slow code + } + + return <li className="item">Text: {text}</li>; +} + +export default SlowList; +``` + +```css +.items { + padding: 0; +} + +.item { + list-style: none; + display: block; + height: 40px; + padding: 5px; + margin-top: 10px; + border-radius: 4px; + border: 1px solid #aaa; +} +``` + +#### Unoptimized re-rendering of the list + +In this example, each item in the `SlowList` component is **artificially slowed down**, but there is no `useDeferredValue`. + +Notice how typing into the input feels very janky. This is because without `useDeferredValue`, each keystroke forces the entire list to re-render immediately in a non-interruptible way. + +```js +import { useState } from "react"; +import SlowList from "./SlowList.js"; + +export default function App() { + const [text, setText] = useState(""); + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <SlowList text={text} /> + </> + ); +} +``` + +```js +import { memo } from "react"; + +const SlowList = memo(function SlowList({ text }) { + // Log once. The actual slowdown is inside SlowItem. + console.log("[ARTIFICIALLY SLOW] Rendering 250 <SlowItem />"); + + let items = []; + for (let i = 0; i < 250; i++) { + items.push(<SlowItem key={i} text={text} />); + } + return <ul className="items">{items}</ul>; +}); + +function SlowItem({ text }) { + let startTime = performance.now(); + while (performance.now() - startTime < 1) { + // Do nothing for 1 ms per item to emulate extremely slow code + } + + return <li className="item">Text: {text}</li>; +} + +export default SlowList; +``` + +```css +.items { + padding: 0; +} + +.item { + list-style: none; + display: block; + height: 40px; + padding: 5px; + margin-top: 10px; + border-radius: 4px; + border: 1px solid #aaa; +} +``` + +</Recipes> + +<Pitfall> + +This optimization requires `SlowList` to be wrapped in [`memo`.](/reference/react/memo) This is because whenever the `text` changes, React needs to be able to re-render the parent component quickly. During that re-render, `deferredText` still has its previous value, so `SlowList` is able to skip re-rendering (its props have not changed). Without [`memo`,](/reference/react/memo) it would have to re-render anyway, defeating the point of the optimization. + +</Pitfall> + +<DeepDive> + +#### How is deferring a value different from debouncing and throttling? + +There are two common optimization techniques you might have used before in this scenario: + +- _Debouncing_ means you'd wait for the user to stop typing (e.g. for a second) before updating the list. +- _Throttling_ means you'd update the list every once in a while (e.g. at most once a second). + +While these techniques are helpful in some cases, `useDeferredValue` is better suited to optimizing rendering because it is deeply integrated with React itself and adapts to the user's device. + +Unlike debouncing or throttling, it doesn't require choosing any fixed delay. If the user's device is fast (e.g. powerful laptop), the deferred re-render would happen almost immediately and wouldn't be noticeable. If the user's device is slow, the list would "lag behind" the input proportionally to how slow the device is. + +Also, unlike with debouncing or throttling, deferred re-renders done by `useDeferredValue` are interruptible by default. This means that if React is in the middle of re-rendering a large list, but the user makes another keystroke, React will abandon that re-render, handle the keystroke, and then start rendering in background again. By contrast, debouncing and throttling still produce a janky experience because they're _blocking:_ they merely postpone the moment when rendering blocks the keystroke. + +If the work you're optimizing doesn't happen during rendering, debouncing and throttling are still useful. For example, they can let you fire fewer network requests. You can also use these techniques together. + +</DeepDive> --> diff --git a/docs/src/reference/use-effect.md b/docs/src/reference/use-effect.md new file mode 100644 index 000000000..c11ea69cd --- /dev/null +++ b/docs/src/reference/use-effect.md @@ -0,0 +1,1860 @@ +## Overview + +<p class="intro" markdown> + +`useEffect` is a React Hook that lets you [synchronize a component with an external system.](/learn/synchronizing-with-effects) + +```js +useEffect(setup, dependencies?) +``` + +</p> + +--- + +## Reference + +### `useEffect(setup, dependencies?)` + +Call `useEffect` at the top level of your component to declare an Effect: + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); + // ... +} +``` + +[See more examples below.](#usage) + +#### Parameters + +- `setup`: The function with your Effect's logic. Your setup function may also optionally return a _cleanup_ function. When your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values. After your component is removed from the DOM, React will run your cleanup function. + +- **optional** `dependencies`: The list of all reactive values referenced inside of the `setup` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. If you omit this argument, your Effect will re-run after every re-render of the component. [See the difference between passing an array of dependencies, an empty array, and no dependencies at all.](#examples-dependencies) + +#### Returns + +`useEffect` returns `undefined`. + +#### Caveats + +- `useEffect` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. + +- If you're **not trying to synchronize with some external system,** [you probably don't need an Effect.](/learn/you-might-not-need-an-effect) + +- When Strict Mode is on, React will **run one extra development-only setup+cleanup cycle** before the first real setup. This is a stress-test that ensures that your cleanup logic "mirrors" your setup logic and that it stops or undoes whatever the setup is doing. If this causes a problem, [implement the cleanup function.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +- If some of your dependencies are objects or functions defined inside the component, there is a risk that they will **cause the Effect to re-run more often than needed.** To fix this, remove unnecessary [object](#removing-unnecessary-object-dependencies) and [function](#removing-unnecessary-function-dependencies) dependencies. You can also [extract state updates](#updating-state-based-on-previous-state-from-an-effect) and [non-reactive logic](#reading-the-latest-props-and-state-from-an-effect) outside of your Effect. + +- If your Effect wasn't caused by an interaction (like a click), React will let the browser **paint the updated screen first before running your Effect.** If your Effect is doing something visual (for example, positioning a tooltip), and the delay is noticeable (for example, it flickers), replace `useEffect` with [`useLayoutEffect`.](/reference/react/useLayoutEffect) + +- Even if your Effect was caused by an interaction (like a click), **the browser may repaint the screen before processing the state updates inside your Effect.** Usually, that's what you want. However, if you must block the browser from repainting the screen, you need to replace `useEffect` with [`useLayoutEffect`.](/reference/react/useLayoutEffect) + +- Effects **only run on the client.** They don't run during server rendering. + +--- + +## Usage + +### Connecting to an external system + +Some components need to stay connected to the network, some browser API, or a third-party library, while they are displayed on the page. These systems aren't controlled by React, so they are called _external._ + +To [connect your component to some external system,](/learn/synchronizing-with-effects) call `useEffect` at the top level of your component: + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); + // ... +} +``` + +You need to pass two arguments to `useEffect`: + +1. A _setup function_ with <CodeStep step={1}>setup code</CodeStep> that connects to that system. + - It should return a _cleanup function_ with <CodeStep step={2}>cleanup code</CodeStep> that disconnects from that system. +2. A <CodeStep step={3}>list of dependencies</CodeStep> including every value from your component used inside of those functions. + +**React calls your setup and cleanup functions whenever it's necessary, which may happen multiple times:** + +1. Your <CodeStep step={1}>setup code</CodeStep> runs when your component is added to the page _(mounts)_. +2. After every re-render of your component where the <CodeStep step={3}>dependencies</CodeStep> have changed: + - First, your <CodeStep step={2}>cleanup code</CodeStep> runs with the old props and state. + - Then, your <CodeStep step={1}>setup code</CodeStep> runs with the new props and state. +3. Your <CodeStep step={2}>cleanup code</CodeStep> runs one final time after your component is removed from the page _(unmounts)._ + +**Let's illustrate this sequence for the example above.** + +When the `ChatRoom` component above gets added to the page, it will connect to the chat room with the initial `serverUrl` and `roomId`. If either `serverUrl` or `roomId` change as a result of a re-render (say, if the user picks a different chat room in a dropdown), your Effect will _disconnect from the previous room, and connect to the next one._ When the `ChatRoom` component is removed from the page, your Effect will disconnect one last time. + +**To [help you find bugs,](/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed) in development React runs <CodeStep step={1}>setup</CodeStep> and <CodeStep step={2}>cleanup</CodeStep> one extra time before the <CodeStep step={1}>setup</CodeStep>.** This is a stress-test that verifies your Effect's logic is implemented correctly. If this causes visible issues, your cleanup function is missing some logic. The cleanup function should stop or undo whatever the setup function was doing. The rule of thumb is that the user shouldn't be able to distinguish between the setup being called once (as in production) and a _setup_ → _cleanup_ → _setup_ sequence (as in development). [See common solutions.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +**Try to [write every Effect as an independent process](/learn/lifecycle-of-reactive-effects#each-effect-represents-a-separate-synchronization-process) and [think about a single setup/cleanup cycle at a time.](/learn/lifecycle-of-reactive-effects#thinking-from-the-effects-perspective)** It shouldn't matter whether your component is mounting, updating, or unmounting. When your cleanup logic correctly "mirrors" the setup logic, your Effect is resilient to running setup and cleanup as often as needed. + +<Note> + +An Effect lets you [keep your component synchronized](/learn/synchronizing-with-effects) with some external system (like a chat service). Here, _external system_ means any piece of code that's not controlled by React, such as: + +- A timer managed with <CodeStep step={1}>[`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval)</CodeStep> and <CodeStep step={2}>[`clearInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval)</CodeStep>. +- An event subscription using <CodeStep step={1}>[`window.addEventListener()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener)</CodeStep> and <CodeStep step={2}>[`window.removeEventListener()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener)</CodeStep>. +- A third-party animation library with an API like <CodeStep step={1}>`animation.start()`</CodeStep> and <CodeStep step={2}>`animation.reset()`</CodeStep>. + +**If you're not connecting to any external system, [you probably don't need an Effect.](/learn/you-might-not-need-an-effect)** + +</Note> + +<Recipes titleText="Examples of connecting to an external system" titleId="examples-connecting"> + +#### Connecting to a chat server + +In this example, the `ChatRoom` component uses an Effect to stay connected to an external system defined in `chat.js`. Press "Open chat" to make the `ChatRoom` component appear. This sandbox runs in development mode, so there is an extra connect-and-disconnect cycle, as [explained here.](/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed) Try changing the `roomId` and `serverUrl` using the dropdown and the input, and see how the Effect re-connects to the chat. Press "Close chat" to see the Effect disconnect one last time. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [show, setShow] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <button onClick={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +#### Listening to a global browser event + +In this example, the external system is the browser DOM itself. Normally, you'd specify event listeners with JSX, but you can't listen to the global [`window`](https://developer.mozilla.org/en-US/docs/Web/API/Window) object this way. An Effect lets you connect to the `window` object and listen to its events. Listening to the `pointermove` event lets you track the cursor (or finger) position and update the red dot to move with it. + +```js +import { useState, useEffect } from "react"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + window.addEventListener("pointermove", handleMove); + return () => { + window.removeEventListener("pointermove", handleMove); + }; + }, []); + + return ( + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + ); +} +``` + +```css +body { + min-height: 300px; +} +``` + +#### Triggering an animation + +In this example, the external system is the animation library in `animation.js`. It provides a JavaScript class called `FadeInAnimation` that takes a DOM node as an argument and exposes `start()` and `stop()` methods to control the animation. This component [uses a ref](/learn/manipulating-the-dom-with-refs) to access the underlying DOM node. The Effect reads the DOM node from the ref and automatically starts the animation for that node when the component appears. + +```js +import { useState, useEffect, useRef } from "react"; +import { FadeInAnimation } from "./animation.js"; + +function Welcome() { + const ref = useRef(null); + + useEffect(() => { + const animation = new FadeInAnimation(ref.current); + animation.start(1000); + return () => { + animation.stop(); + }; + }, []); + + return ( + <h1 + ref={ref} + style={{ + opacity: 0, + color: "white", + padding: 50, + textAlign: "center", + fontSize: 50, + backgroundImage: + "radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%)", + }} + > + Welcome + </h1> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button onClick={() => setShow(!show)}> + {show ? "Remove" : "Show"} + </button> + <hr /> + {show && <Welcome />} + </> + ); +} +``` + +```js +export class FadeInAnimation { + constructor(node) { + this.node = node; + } + start(duration) { + this.duration = duration; + if (this.duration === 0) { + // Jump to end immediately + this.onProgress(1); + } else { + this.onProgress(0); + // Start animating + this.startTime = performance.now(); + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onFrame() { + const timePassed = performance.now() - this.startTime; + const progress = Math.min(timePassed / this.duration, 1); + this.onProgress(progress); + if (progress < 1) { + // We still have more frames to paint + this.frameId = requestAnimationFrame(() => this.onFrame()); + } + } + onProgress(progress) { + this.node.style.opacity = progress; + } + stop() { + cancelAnimationFrame(this.frameId); + this.startTime = null; + this.frameId = null; + this.duration = 0; + } +} +``` + +```css +label, +button { + display: block; + margin-bottom: 20px; +} +html, +body { + min-height: 300px; +} +``` + +#### Controlling a modal dialog + +In this example, the external system is the browser DOM. The `ModalDialog` component renders a [`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) element. It uses an Effect to synchronize the `isOpen` prop to the [`showModal()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) and [`close()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close) method calls. + +```js +import { useState } from "react"; +import ModalDialog from "./ModalDialog.js"; + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button onClick={() => setShow(true)}>Open dialog</button> + <ModalDialog isOpen={show}> + Hello there! + <br /> + <button + onClick={() => { + setShow(false); + }} + > + Close + </button> + </ModalDialog> + </> + ); +} +``` + +```js +import { useEffect, useRef } from "react"; + +export default function ModalDialog({ isOpen, children }) { + const ref = useRef(); + + useEffect(() => { + if (!isOpen) { + return; + } + const dialog = ref.current; + dialog.showModal(); + return () => { + dialog.close(); + }; + }, [isOpen]); + + return <dialog ref={ref}>{children}</dialog>; +} +``` + +```css +body { + min-height: 300px; +} +``` + +#### Tracking element visibility + +In this example, the external system is again the browser DOM. The `App` component displays a long list, then a `Box` component, and then another long list. Scroll the list down. Notice that when the `Box` component appears in the viewport, the background color changes to black. To implement this, the `Box` component uses an Effect to manage an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). This browser API notifies you when the DOM element is visible in the viewport. + +```js +import Box from "./Box.js"; + +export default function App() { + return ( + <> + <LongSection /> + <Box /> + <LongSection /> + <Box /> + <LongSection /> + </> + ); +} + +function LongSection() { + const items = []; + for (let i = 0; i < 50; i++) { + items.push(<li key={i}>Item #{i} (keep scrolling)</li>); + } + return <ul>{items}</ul>; +} +``` + +```js +import { useRef, useEffect } from "react"; + +export default function Box() { + const ref = useRef(null); + + useEffect(() => { + const div = ref.current; + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + if (entry.isIntersecting) { + document.body.style.backgroundColor = "black"; + document.body.style.color = "white"; + } else { + document.body.style.backgroundColor = "white"; + document.body.style.color = "black"; + } + }); + observer.observe(div, { + threshold: 1.0, + }); + return () => { + observer.disconnect(); + }; + }, []); + + return ( + <div + ref={ref} + style={{ + margin: 20, + height: 100, + width: 100, + border: "2px solid black", + backgroundColor: "blue", + }} + /> + ); +} +``` + +</Recipes> + +--- + +### Wrapping Effects in custom Hooks + +Effects are an ["escape hatch":](/learn/escape-hatches) you use them when you need to "step outside React" and when there is no better built-in solution for your use case. If you find yourself often needing to manually write Effects, it's usually a sign that you need to extract some [custom Hooks](/learn/reusing-logic-with-custom-hooks) for common behaviors your components rely on. + +For example, this `useChatRoom` custom Hook "hides" the logic of your Effect behind a more declarative API: + +```js +function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +Then you can use it from any component like this: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + // ... +``` + +There are also many excellent custom Hooks for every purpose available in the React ecosystem. + +[Learn more about wrapping Effects in custom Hooks.](/learn/reusing-logic-with-custom-hooks) + +<Recipes titleText="Examples of wrapping Effects in custom Hooks" titleId="examples-custom-hooks"> + +#### Custom `useChatRoom` Hook + +This example is identical to one of the [earlier examples,](#examples-connecting) but the logic is extracted to a custom Hook. + +```js +import { useState } from "react"; +import { useChatRoom } from "./useChatRoom.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + }); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + const [show, setShow] = useState(false); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <button onClick={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +import { useEffect } from "react"; +import { createConnection } from "./chat.js"; + +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +#### Custom `useWindowListener` Hook + +This example is identical to one of the [earlier examples,](#examples-connecting) but the logic is extracted to a custom Hook. + +```js +import { useState } from "react"; +import { useWindowListener } from "./useWindowListener.js"; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useWindowListener("pointermove", (e) => { + setPosition({ x: e.clientX, y: e.clientY }); + }); + + return ( + <div + style={{ + position: "absolute", + backgroundColor: "pink", + borderRadius: "50%", + opacity: 0.6, + transform: `translate(${position.x}px, ${position.y}px)`, + pointerEvents: "none", + left: -20, + top: -20, + width: 40, + height: 40, + }} + /> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useWindowListener(eventType, listener) { + useEffect(() => { + window.addEventListener(eventType, listener); + return () => { + window.removeEventListener(eventType, listener); + }; + }, [eventType, listener]); +} +``` + +```css +body { + min-height: 300px; +} +``` + +#### Custom `useIntersectionObserver` Hook + +This example is identical to one of the [earlier examples,](#examples-connecting) but the logic is partially extracted to a custom Hook. + +```js +import Box from "./Box.js"; + +export default function App() { + return ( + <> + <LongSection /> + <Box /> + <LongSection /> + <Box /> + <LongSection /> + </> + ); +} + +function LongSection() { + const items = []; + for (let i = 0; i < 50; i++) { + items.push(<li key={i}>Item #{i} (keep scrolling)</li>); + } + return <ul>{items}</ul>; +} +``` + +```js +import { useRef, useEffect } from "react"; +import { useIntersectionObserver } from "./useIntersectionObserver.js"; + +export default function Box() { + const ref = useRef(null); + const isIntersecting = useIntersectionObserver(ref); + + useEffect(() => { + if (isIntersecting) { + document.body.style.backgroundColor = "black"; + document.body.style.color = "white"; + } else { + document.body.style.backgroundColor = "white"; + document.body.style.color = "black"; + } + }, [isIntersecting]); + + return ( + <div + ref={ref} + style={{ + margin: 20, + height: 100, + width: 100, + border: "2px solid black", + backgroundColor: "blue", + }} + /> + ); +} +``` + +```js +import { useState, useEffect } from "react"; + +export function useIntersectionObserver(ref) { + const [isIntersecting, setIsIntersecting] = useState(false); + + useEffect(() => { + const div = ref.current; + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + setIsIntersecting(entry.isIntersecting); + }); + observer.observe(div, { + threshold: 1.0, + }); + return () => { + observer.disconnect(); + }; + }, [ref]); + + return isIntersecting; +} +``` + +</Recipes> + +--- + +### Controlling a non-React widget + +Sometimes, you want to keep an external system synchronized to some prop or state of your component. + +For example, if you have a third-party map widget or a video player component written without React, you can use an Effect to call methods on it that make its state match the current state of your React component. This Effect creates an instance of a `MapWidget` class defined in `map-widget.js`. When you change the `zoomLevel` prop of the `Map` component, the Effect calls the `setZoom()` on the class instance to keep it synchronized: + +```json package.json hidden +{ + "dependencies": { + "leaflet": "1.9.1", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "remarkable": "2.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { useState } from "react"; +import Map from "./Map.js"; + +export default function App() { + const [zoomLevel, setZoomLevel] = useState(0); + return ( + <> + Zoom level: {zoomLevel}x + <button onClick={() => setZoomLevel(zoomLevel + 1)}>+</button> + <button onClick={() => setZoomLevel(zoomLevel - 1)}>-</button> + <hr /> + <Map zoomLevel={zoomLevel} /> + </> + ); +} +``` + +```js +import { useRef, useEffect } from "react"; +import { MapWidget } from "./map-widget.js"; + +export default function Map({ zoomLevel }) { + const containerRef = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + if (mapRef.current === null) { + mapRef.current = new MapWidget(containerRef.current); + } + + const map = mapRef.current; + map.setZoom(zoomLevel); + }, [zoomLevel]); + + return <div style={{ width: 200, height: 200 }} ref={containerRef} />; +} +``` + +```js +import "leaflet/dist/leaflet.css"; +import * as L from "leaflet"; + +export class MapWidget { + constructor(domNode) { + this.map = L.map(domNode, { + zoomControl: false, + doubleClickZoom: false, + boxZoom: false, + keyboard: false, + scrollWheelZoom: false, + zoomAnimation: false, + touchZoom: false, + zoomSnap: 0.1, + }); + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap", + }).addTo(this.map); + this.map.setView([0, 0], 0); + } + setZoom(level) { + this.map.setZoom(level); + } +} +``` + +```css +button { + margin: 5px; +} +``` + +In this example, a cleanup function is not needed because the `MapWidget` class manages only the DOM node that was passed to it. After the `Map` React component is removed from the tree, both the DOM node and the `MapWidget` class instance will be automatically garbage-collected by the browser JavaScript engine. + +--- + +### Fetching data with Effects + +You can use an Effect to fetch data for your component. Note that [if you use a framework,](/learn/start-a-new-react-project#production-grade-react-frameworks) using your framework's data fetching mechanism will be a lot more efficient than writing Effects manually. + +If you want to fetch data from an Effect manually, your code might look like this: + +```js +import { useState, useEffect } from 'react'; +import { fetchBio } from './api.js'; + +export default function Page() { + const [person, setPerson] = useState('Alice'); + const [bio, setBio] = useState(null); + + useEffect(() => { + let ignore = false; + setBio(null); + fetchBio(person).then(result => { + if (!ignore) { + setBio(result); + } + }); + return () => { + ignore = true; + }; + }, [person]); + + // ... +``` + +Note the `ignore` variable which is initialized to `false`, and is set to `true` during cleanup. This ensures [your code doesn't suffer from "race conditions":](https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect) network responses may arrive in a different order than you sent them. + +```js +import { useState, useEffect } from "react"; +import { fetchBio } from "./api.js"; + +export default function Page() { + const [person, setPerson] = useState("Alice"); + const [bio, setBio] = useState(null); + useEffect(() => { + let ignore = false; + setBio(null); + fetchBio(person).then((result) => { + if (!ignore) { + setBio(result); + } + }); + return () => { + ignore = true; + }; + }, [person]); + + return ( + <> + <select + value={person} + onChange={(e) => { + setPerson(e.target.value); + }} + > + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + <option value="Taylor">Taylor</option> + </select> + <hr /> + <p> + <i>{bio ?? "Loading..."}</i> + </p> + </> + ); +} +``` + +```js +export async function fetchBio(person) { + const delay = person === "Bob" ? 2000 : 200; + return new Promise((resolve) => { + setTimeout(() => { + resolve("This is " + person + "’s bio."); + }, delay); + }); +} +``` + +You can also rewrite using the [`async` / `await`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) syntax, but you still need to provide a cleanup function: + +```js +import { useState, useEffect } from "react"; +import { fetchBio } from "./api.js"; + +export default function Page() { + const [person, setPerson] = useState("Alice"); + const [bio, setBio] = useState(null); + useEffect(() => { + async function startFetching() { + setBio(null); + const result = await fetchBio(person); + if (!ignore) { + setBio(result); + } + } + + let ignore = false; + startFetching(); + return () => { + ignore = true; + }; + }, [person]); + + return ( + <> + <select + value={person} + onChange={(e) => { + setPerson(e.target.value); + }} + > + <option value="Alice">Alice</option> + <option value="Bob">Bob</option> + <option value="Taylor">Taylor</option> + </select> + <hr /> + <p> + <i>{bio ?? "Loading..."}</i> + </p> + </> + ); +} +``` + +```js +export async function fetchBio(person) { + const delay = person === "Bob" ? 2000 : 200; + return new Promise((resolve) => { + setTimeout(() => { + resolve("This is " + person + "’s bio."); + }, delay); + }); +} +``` + +Writing data fetching directly in Effects gets repetitive and makes it difficult to add optimizations like caching and server rendering later. [It's easier to use a custom Hook--either your own or maintained by the community.](/learn/reusing-logic-with-custom-hooks#when-to-use-custom-hooks) + +<DeepDive> + +#### What are good alternatives to data fetching in Effects? + +Writing `fetch` calls inside Effects is a [popular way to fetch data](https://www.robinwieruch.de/react-hooks-fetch-data/), especially in fully client-side apps. This is, however, a very manual approach and it has significant downsides: + +- **Effects don't run on the server.** This means that the initial server-rendered HTML will only include a loading state with no data. The client computer will have to download all JavaScript and render your app only to discover that now it needs to load the data. This is not very efficient. +- **Fetching directly in Effects makes it easy to create "network waterfalls".** You render the parent component, it fetches some data, renders the child components, and then they start fetching their data. If the network is not very fast, this is significantly slower than fetching all data in parallel. +- **Fetching directly in Effects usually means you don't preload or cache data.** For example, if the component unmounts and then mounts again, it would have to fetch the data again. +- **It's not very ergonomic.** There's quite a bit of boilerplate code involved when writing `fetch` calls in a way that doesn't suffer from bugs like [race conditions.](https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect) + +This list of downsides is not specific to React. It applies to fetching data on mount with any library. Like with routing, data fetching is not trivial to do well, so we recommend the following approaches: + +- **If you use a [framework](/learn/start-a-new-react-project#production-grade-react-frameworks), use its built-in data fetching mechanism.** Modern React frameworks have integrated data fetching mechanisms that are efficient and don't suffer from the above pitfalls. +- **Otherwise, consider using or building a client-side cache.** Popular open source solutions include [React Query](https://react-query.tanstack.com/), [useSWR](https://swr.vercel.app/), and [React Router 6.4+.](https://beta.reactrouter.com/en/main/start/overview) You can build your own solution too, in which case you would use Effects under the hood but also add logic for deduplicating requests, caching responses, and avoiding network waterfalls (by preloading data or hoisting data requirements to routes). + +You can continue fetching data directly in Effects if neither of these approaches suit you. + +</DeepDive> + +--- + +### Specifying reactive dependencies + +**Notice that you can't "choose" the dependencies of your Effect.** Every <CodeStep step={2}>reactive value</CodeStep> used by your Effect's code must be declared as a dependency. Your Effect's dependency list is determined by the surrounding code: + +```js +function ChatRoom({ roomId }) { + // This is a reactive value + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); // This is a reactive value too + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // This Effect reads these reactive values + connection.connect(); + return () => connection.disconnect(); + }, [serverUrl, roomId]); // ✅ So you must specify them as dependencies of your Effect + // ... +} +``` + +If either `serverUrl` or `roomId` change, your Effect will reconnect to the chat using the new values. + +**[Reactive values](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) include props and all variables and functions declared directly inside of your component.** Since `roomId` and `serverUrl` are reactive values, you can't remove them from the dependencies. If you try to omit them and [your linter is correctly configured for React,](/learn/editor-setup#linting) the linter will flag this as a mistake you need to fix: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // 🔴 React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl' + // ... +} +``` + +**To remove a dependency, you need to ["prove" to the linter that it _doesn't need_ to be a dependency.](/learn/removing-effect-dependencies#removing-unnecessary-dependencies)** For example, you can move `serverUrl` out of your component to prove that it's not reactive and won't change on re-renders: + +```js +const serverUrl = "https://localhost:1234"; // Not a reactive value anymore + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); // ✅ All dependencies declared + // ... +} +``` + +Now that `serverUrl` is not a reactive value (and can't change on a re-render), it doesn't need to be a dependency. **If your Effect's code doesn't use any reactive values, its dependency list should be empty (`[]`):** + +```js +const serverUrl = "https://localhost:1234"; // Not a reactive value anymore +const roomId = "music"; // Not a reactive value anymore + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // ✅ All dependencies declared + // ... +} +``` + +[An Effect with empty dependencies](/learn/lifecycle-of-reactive-effects#what-an-effect-with-empty-dependencies-means) doesn't re-run when any of your component's props or state change. + +<Pitfall> + +If you have an existing codebase, you might have some Effects that suppress the linter like this: + +```js +useEffect(() => { + // ... + // 🔴 Avoid suppressing the linter like this: + // eslint-ignore-next-line react-hooks/exhaustive-deps +}, []); +``` + +**When dependencies don't match the code, there is a high risk of introducing bugs.** By suppressing the linter, you "lie" to React about the values your Effect depends on. [Instead, prove they're unnecessary.](/learn/removing-effect-dependencies#removing-unnecessary-dependencies) + +</Pitfall> + +<Recipes titleText="Examples of passing reactive dependencies" titleId="examples-dependencies"> + +#### Passing a dependency array + +If you specify the dependencies, your Effect runs **after the initial render _and_ after re-renders with changed dependencies.** + +```js +useEffect(() => { + // ... +}, [a, b]); // Runs again if a or b are different +``` + +In the below example, `serverUrl` and `roomId` are [reactive values,](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) so they both must be specified as dependencies. As a result, selecting a different room in the dropdown or editing the server URL input causes the chat to re-connect. However, since `message` isn't used in the Effect (and so it isn't a dependency), editing the message doesn't re-connect to the chat. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + <label> + Your message:{" "} + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </label> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> + </label> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + margin-bottom: 10px; +} +button { + margin-left: 5px; +} +``` + +#### Passing an empty dependency array + +If your Effect truly doesn't use any reactive values, it will only run **after the initial render.** + +```js +useEffect(() => { + // ... +}, []); // Does not run again (except once in development) +``` + +**Even with empty dependencies, setup and cleanup will [run one extra time in development](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) to help you find bugs.** + +In this example, both `serverUrl` and `roomId` are hardcoded. Since they're declared outside the component, they are not reactive values, and so they aren't dependencies. The dependency list is empty, so the Effect doesn't re-run on re-renders. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; +const roomId = "music"; + +function ChatRoom() { + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <label> + Your message:{" "} + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </label> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + <button onClick={() => setShow(!show)}> + {show ? "Close chat" : "Open chat"} + </button> + {show && <hr />} + {show && <ChatRoom />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +#### Passing no dependency array at all + +If you pass no dependency array at all, your Effect runs **after every single render (and re-render)** of your component. + +```js +useEffect(() => { + // ... +}); // Always runs again +``` + +In this example, the Effect re-runs when you change `serverUrl` and `roomId`, which is sensible. However, it _also_ re-runs when you change the `message`, which is probably undesirable. This is why usually you'll specify the dependency array. + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState("https://localhost:1234"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }); // No dependency array at all + + return ( + <> + <label> + Server URL:{" "} + <input + value={serverUrl} + onChange={(e) => setServerUrl(e.target.value)} + /> + </label> + <h1>Welcome to the {roomId} room!</h1> + <label> + Your message:{" "} + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </label> + </> + ); +} + +export default function App() { + const [show, setShow] = useState(false); + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> + </label> + {show && <hr />} + {show && <ChatRoom roomId={roomId} />} + </> + ); +} +``` + +```js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + margin-bottom: 10px; +} +button { + margin-left: 5px; +} +``` + +</Recipes> + +--- + +### Updating state based on previous state from an Effect + +When you want to update state based on previous state from an Effect, you might run into a problem: + +```js +function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + const intervalId = setInterval(() => { + setCount(count + 1); // You want to increment the counter every second... + }, 1000); + return () => clearInterval(intervalId); + }, [count]); // 🚩 ... but specifying `count` as a dependency always resets the interval. + // ... +} +``` + +Since `count` is a reactive value, it must be specified in the list of dependencies. However, that causes the Effect to cleanup and setup again every time the `count` changes. This is not ideal. + +To fix this, [pass the `c => c + 1` state updater](/reference/react/useState#updating-state-based-on-the-previous-state) to `setCount`: + +```js +import { useState, useEffect } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + const intervalId = setInterval(() => { + setCount((c) => c + 1); // ✅ Pass a state updater + }, 1000); + return () => clearInterval(intervalId); + }, []); // ✅ Now count is not a dependency + + return <h1>{count}</h1>; +} +``` + +```css +label { + display: block; + margin-top: 20px; + margin-bottom: 20px; +} + +body { + min-height: 150px; +} +``` + +Now that you're passing `c => c + 1` instead of `count + 1`, [your Effect no longer needs to depend on `count`.](/learn/removing-effect-dependencies#are-you-reading-some-state-to-calculate-the-next-state) As a result of this fix, it won't need to cleanup and setup the interval again every time the `count` changes. + +--- + +### Removing unnecessary object dependencies + +If your Effect depends on an object or a function created during rendering, it might run too often. For example, this Effect re-connects after every render because the `options` object is [different for every render:](/learn/removing-effect-dependencies#does-some-reactive-value-change-unintentionally) + +```js +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + const options = { // 🚩 This object is created from scratch on every re-render + serverUrl: serverUrl, + roomId: roomId + }; + + useEffect(() => { + const connection = createConnection(options); // It's used inside the Effect + connection.connect(); + return () => connection.disconnect(); + }, [options]); // 🚩 As a result, these dependencies are always different on a re-render + // ... +``` + +Avoid using an object created during rendering as a dependency. Instead, create the object inside the Effect: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId, + }; + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Now that you create the `options` object inside the Effect, the Effect itself only depends on the `roomId` string. + +With this fix, typing into the input doesn't reconnect the chat. Unlike an object which gets re-created, a string like `roomId` doesn't change unless you set it to another value. [Read more about removing dependencies.](/learn/removing-effect-dependencies) + +--- + +### Removing unnecessary function dependencies + +If your Effect depends on an object or a function created during rendering, it might run too often. For example, this Effect re-connects after every render because the `createOptions` function is [different for every render:](/learn/removing-effect-dependencies#does-some-reactive-value-change-unintentionally) + +```js +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + function createOptions() { // 🚩 This function is created from scratch on every re-render + return { + serverUrl: serverUrl, + roomId: roomId + }; + } + + useEffect(() => { + const options = createOptions(); // It's used inside the Effect + const connection = createConnection(); + connection.connect(); + return () => connection.disconnect(); + }, [createOptions]); // 🚩 As a result, these dependencies are always different on a re-render + // ... +``` + +By itself, creating a function from scratch on every re-render is not a problem. You don't need to optimize that. However, if you use it as a dependency of your Effect, it will cause your Effect to re-run after every re-render. + +Avoid using a function created during rendering as a dependency. Instead, declare it inside the Effect: + +```js +import { useState, useEffect } from "react"; +import { createConnection } from "./chat.js"; + +const serverUrl = "https://localhost:1234"; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(""); + + useEffect(() => { + function createOptions() { + return { + serverUrl: serverUrl, + roomId: roomId, + }; + } + + const options = createOptions(); + const connection = createConnection(options); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> + <h1>Welcome to the {roomId} room!</h1> + <input + value={message} + onChange={(e) => setMessage(e.target.value)} + /> + </> + ); +} + +export default function App() { + const [roomId, setRoomId] = useState("general"); + return ( + <> + <label> + Choose the chat room:{" "} + <select + value={roomId} + onChange={(e) => setRoomId(e.target.value)} + > + <option value="general">general</option> + <option value="travel">travel</option> + <option value="music">music</option> + </select> + </label> + <hr /> + <ChatRoom roomId={roomId} /> + </> + ); +} +``` + +```js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + return { + connect() { + console.log( + '✅ Connecting to "' + roomId + '" room at ' + serverUrl + "..." + ); + }, + disconnect() { + console.log( + '❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ); + }, + }; +} +``` + +```css +input { + display: block; + margin-bottom: 20px; +} +button { + margin-left: 10px; +} +``` + +Now that you define the `createOptions` function inside the Effect, the Effect itself only depends on the `roomId` string. With this fix, typing into the input doesn't reconnect the chat. Unlike a function which gets re-created, a string like `roomId` doesn't change unless you set it to another value. [Read more about removing dependencies.](/learn/removing-effect-dependencies) + +--- + +### Reading the latest props and state from an Effect + +<Wip> + +This section describes an **experimental API that has not yet been released** in a stable version of React. + +</Wip> + +By default, when you read a reactive value from an Effect, you have to add it as a dependency. This ensures that your Effect "reacts" to every change of that value. For most dependencies, that's the behavior you want. + +**However, sometimes you'll want to read the _latest_ props and state from an Effect without "reacting" to them.** For example, imagine you want to log the number of the items in the shopping cart for every page visit: + +```js +function Page({ url, shoppingCart }) { + useEffect(() => { + logVisit(url, shoppingCart.length); + }, [url, shoppingCart]); // ✅ All dependencies declared + // ... +} +``` + +**What if you want to log a new page visit after every `url` change, but _not_ if only the `shoppingCart` changes?** You can't exclude `shoppingCart` from dependencies without breaking the [reactivity rules.](#specifying-reactive-dependencies) However, you can express that you _don't want_ a piece of code to "react" to changes even though it is called from inside an Effect. [Declare an _Effect Event_](/learn/separating-events-from-effects#declaring-an-effect-event) with the [`useEffectEvent`](/reference/react/experimental_useEffectEvent) Hook, and move the code reading `shoppingCart` inside of it: + +```js +function Page({ url, shoppingCart }) { + const onVisit = useEffectEvent((visitedUrl) => { + logVisit(visitedUrl, shoppingCart.length); + }); + + useEffect(() => { + onVisit(url); + }, [url]); // ✅ All dependencies declared + // ... +} +``` + +**Effect Events are not reactive and must always be omitted from dependencies of your Effect.** This is what lets you put non-reactive code (where you can read the latest value of some props and state) inside of them. By reading `shoppingCart` inside of `onVisit`, you ensure that `shoppingCart` won't re-run your Effect. + +[Read more about how Effect Events let you separate reactive and non-reactive code.](/learn/separating-events-from-effects#reading-latest-props-and-state-with-effect-events) + +--- + +### Displaying different content on the server and the client + +If your app uses server rendering (either [directly](/reference/react-dom/server) or via a [framework](/learn/start-a-new-react-project#production-grade-react-frameworks)), your component will render in two different environments. On the server, it will render to produce the initial HTML. On the client, React will run the rendering code again so that it can attach your event handlers to that HTML. This is why, for [hydration](/reference/react-dom/client/hydrateRoot#hydrating-server-rendered-html) to work, your initial render output must be identical on the client and the server. + +In rare cases, you might need to display different content on the client. For example, if your app reads some data from [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage), it can't possibly do that on the server. Here is how you could implement this: + +```js +function MyComponent() { + const [didMount, setDidMount] = useState(false); + + useEffect(() => { + setDidMount(true); + }, []); + + if (didMount) { + // ... return client-only JSX ... + } else { + // ... return initial JSX ... + } +} +``` + +While the app is loading, the user will see the initial render output. Then, when it's loaded and hydrated, your Effect will run and set `didMount` to `true`, triggering a re-render. This will switch to the client-only render output. Effects don't run on the server, so this is why `didMount` was `false` during the initial server render. + +Use this pattern sparingly. Keep in mind that users with a slow connection will see the initial content for quite a bit of time--potentially, many seconds--so you don't want to make jarring changes to your component's appearance. In many cases, you can avoid the need for this by conditionally showing different things with CSS. + +--- + +## Troubleshooting + +### My Effect runs twice when the component mounts + +When Strict Mode is on, in development, React runs setup and cleanup one extra time before the actual setup. + +This is a stress-test that verifies your Effect’s logic is implemented correctly. If this causes visible issues, your cleanup function is missing some logic. The cleanup function should stop or undo whatever the setup function was doing. The rule of thumb is that the user shouldn’t be able to distinguish between the setup being called once (as in production) and a setup → cleanup → setup sequence (as in development). + +Read more about [how this helps find bugs](/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed) and [how to fix your logic.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +--- + +### My Effect runs after every re-render + +First, check that you haven't forgotten to specify the dependency array: + +```js +useEffect(() => { + // ... +}); // 🚩 No dependency array: re-runs after every render! +``` + +If you've specified the dependency array but your Effect still re-runs in a loop, it's because one of your dependencies is different on every re-render. + +You can debug this problem by manually logging your dependencies to the console: + +```js +useEffect(() => { + // .. +}, [serverUrl, roomId]); + +console.log([serverUrl, roomId]); +``` + +You can then right-click on the arrays from different re-renders in the console and select "Store as a global variable" for both of them. Assuming the first one got saved as `temp1` and the second one got saved as `temp2`, you can then use the browser console to check whether each dependency in both arrays is the same: + +```js +Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays? +Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays? +Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ... +``` + +When you find the dependency that is different on every re-render, you can usually fix it in one of these ways: + +- [Updating state based on previous state from an Effect](#updating-state-based-on-previous-state-from-an-effect) +- [Removing unnecessary object dependencies](#removing-unnecessary-object-dependencies) +- [Removing unnecessary function dependencies](#removing-unnecessary-function-dependencies) +- [Reading the latest props and state from an Effect](#reading-the-latest-props-and-state-from-an-effect) + +As a last resort (if these methods didn't help), wrap its creation with [`useMemo`](/reference/react/useMemo#memoizing-a-dependency-of-another-hook) or [`useCallback`](/reference/react/useCallback#preventing-an-effect-from-firing-too-often) (for functions). + +--- + +### My Effect keeps re-running in an infinite cycle + +If your Effect runs in an infinite cycle, these two things must be true: + +- Your Effect is updating some state. +- That state leads to a re-render, which causes the Effect's dependencies to change. + +Before you start fixing the problem, ask yourself whether your Effect is connecting to some external system (like DOM, network, a third-party widget, and so on). Why does your Effect need to set state? Does it synchronize with that external system? Or are you trying to manage your application's data flow with it? + +If there is no external system, consider whether [removing the Effect altogether](/learn/you-might-not-need-an-effect) would simplify your logic. + +If you're genuinely synchronizing with some external system, think about why and under what conditions your Effect should update the state. Has something changed that affects your component's visual output? If you need to keep track of some data that isn't used by rendering, a [ref](/reference/react/useRef#referencing-a-value-with-a-ref) (which doesn't trigger re-renders) might be more appropriate. Verify your Effect doesn't update the state (and trigger re-renders) more than needed. + +Finally, if your Effect is updating the state at the right time, but there is still a loop, it's because that state update leads to one of the Effect's dependencies changing. [Read how to debug dependency changes.](/reference/react/useEffect#my-effect-runs-after-every-re-render) + +--- + +### My cleanup logic runs even though my component didn't unmount + +The cleanup function runs not only during unmount, but before every re-render with changed dependencies. Additionally, in development, React [runs setup+cleanup one extra time immediately after component mounts.](#my-effect-runs-twice-when-the-component-mounts) + +If you have cleanup code without corresponding setup code, it's usually a code smell: + +```js +useEffect(() => { + // 🔴 Avoid: Cleanup logic without corresponding setup logic + return () => { + doSomething(); + }; +}, []); +``` + +Your cleanup logic should be "symmetrical" to the setup logic, and should stop or undo whatever setup did: + +```js +useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; +}, [serverUrl, roomId]); +``` + +[Learn how the Effect lifecycle is different from the component's lifecycle.](/learn/lifecycle-of-reactive-effects#the-lifecycle-of-an-effect) + +--- + +### My Effect does something visual, and I see a flicker before it runs + +If your Effect must block the browser from [painting the screen,](/learn/render-and-commit#epilogue-browser-paint) replace `useEffect` with [`useLayoutEffect`](/reference/react/useLayoutEffect). Note that **this shouldn't be needed for the vast majority of Effects.** You'll only need this if it's crucial to run your Effect before the browser paint: for example, to measure and position a tooltip before the user sees it. diff --git a/docs/src/reference/use-id.md b/docs/src/reference/use-id.md new file mode 100644 index 000000000..be721c721 --- /dev/null +++ b/docs/src/reference/use-id.md @@ -0,0 +1,288 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. + + See [this issue](https://github.com/reactive-python/reactpy/issues/985) for more details. +<!-- +## Overview + +<p class="intro" markdown> + +`useId` is a React Hook for generating unique IDs that can be passed to accessibility attributes. + +```js +const id = useId(); +``` + +</p> + +--- + +## Reference + +### `useId()` + +Call `useId` at the top level of your component to generate a unique ID: + +```js +import { useId } from 'react'; + +function PasswordField() { + const passwordHintId = useId(); + // ... +``` + +[See more examples below.](#usage) + +#### Parameters + +`useId` does not take any parameters. + +#### Returns + +`useId` returns a unique ID string associated with this particular `useId` call in this particular component. + +#### Caveats + +- `useId` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. + +- `useId` **should not be used to generate keys** in a list. [Keys should be generated from your data.](/learn/rendering-lists#where-to-get-your-key) + +--- + +## Usage + +<Pitfall> + +**Do not call `useId` to generate keys in a list.** [Keys should be generated from your data.](/learn/rendering-lists#where-to-get-your-key) + +</Pitfall> + +### Generating unique IDs for accessibility attributes + +Call `useId` at the top level of your component to generate a unique ID: + +```js +import { useId } from 'react'; + +function PasswordField() { + const passwordHintId = useId(); + // ... +``` + +You can then pass the <CodeStep step={1}>generated ID</CodeStep> to different attributes: + +```js +<> + <input type="password" aria-describedby={passwordHintId} /> + <p id={passwordHintId}> +</> +``` + +**Let's walk through an example to see when this is useful.** + +[HTML accessibility attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) like [`aria-describedby`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby) let you specify that two tags are related to each other. For example, you can specify that an element (like an input) is described by another element (like a paragraph). + +In regular HTML, you would write it like this: + +```html {5,8} +<label> + Password: + <input type="password" aria-describedby="password-hint" /> +</label> +<p id="password-hint">The password should contain at least 18 characters</p> +``` + +However, hardcoding IDs like this is not a good practice in React. A component may be rendered more than once on the page--but IDs have to be unique! Instead of hardcoding an ID, generate a unique ID with `useId`: + +```js +import { useId } from "react"; + +function PasswordField() { + const passwordHintId = useId(); + return ( + <> + <label> + Password: + <input type="password" aria-describedby={passwordHintId} /> + </label> + <p id={passwordHintId}> + The password should contain at least 18 characters + </p> + </> + ); +} +``` + +Now, even if `PasswordField` appears multiple times on the screen, the generated IDs won't clash. + +```js +import { useId } from "react"; + +function PasswordField() { + const passwordHintId = useId(); + return ( + <> + <label> + Password: + <input type="password" aria-describedby={passwordHintId} /> + </label> + <p id={passwordHintId}> + The password should contain at least 18 characters + </p> + </> + ); +} + +export default function App() { + return ( + <> + <h2>Choose password</h2> + <PasswordField /> + <h2>Confirm password</h2> + <PasswordField /> + </> + ); +} +``` + +```css +input { + margin: 5px; +} +``` + +[Watch this video](https://www.youtube.com/watch?v=0dNzNcuEuOo) to see the difference in the user experience with assistive technologies. + +<Pitfall> + +With [server rendering](/reference/react-dom/server), **`useId` requires an identical component tree on the server and the client**. If the trees you render on the server and the client don't match exactly, the generated IDs won't match. + +</Pitfall> + +<DeepDive> + +#### Why is useId better than an incrementing counter? + +You might be wondering why `useId` is better than incrementing a global variable like `nextId++`. + +The primary benefit of `useId` is that React ensures that it works with [server rendering.](/reference/react-dom/server) During server rendering, your components generate HTML output. Later, on the client, [hydration](/reference/react-dom/client/hydrateRoot) attaches your event handlers to the generated HTML. For hydration to work, the client output must match the server HTML. + +This is very difficult to guarantee with an incrementing counter because the order in which the client components are hydrated may not match the order in which the server HTML was emitted. By calling `useId`, you ensure that hydration will work, and the output will match between the server and the client. + +Inside React, `useId` is generated from the "parent path" of the calling component. This is why, if the client and the server tree are the same, the "parent path" will match up regardless of rendering order. + +</DeepDive> + +--- + +### Generating IDs for several related elements + +If you need to give IDs to multiple related elements, you can call `useId` to generate a shared prefix for them: + +```js +import { useId } from "react"; + +export default function Form() { + const id = useId(); + return ( + <form> + <label htmlFor={id + "-firstName"}>First Name:</label> + <input id={id + "-firstName"} type="text" /> + <hr /> + <label htmlFor={id + "-lastName"}>Last Name:</label> + <input id={id + "-lastName"} type="text" /> + </form> + ); +} +``` + +```css +input { + margin: 5px; +} +``` + +This lets you avoid calling `useId` for every single element that needs a unique ID. + +--- + +### Specifying a shared prefix for all generated IDs + +If you render multiple independent React applications on a single page, pass `identifierPrefix` as an option to your [`createRoot`](/reference/react-dom/client/createRoot#parameters) or [`hydrateRoot`](/reference/react-dom/client/hydrateRoot) calls. This ensures that the IDs generated by the two different apps never clash because every identifier generated with `useId` will start with the distinct prefix you've specified. + +```html index.html +<!DOCTYPE html> +<html> + <head> + <title>My app</title> + </head> + <body> + <div id="root1"></div> + <div id="root2"></div> + </body> +</html> +``` + +```js +import { useId } from "react"; + +function PasswordField() { + const passwordHintId = useId(); + console.log("Generated identifier:", passwordHintId); + return ( + <> + <label> + Password: + <input type="password" aria-describedby={passwordHintId} /> + </label> + <p id={passwordHintId}> + The password should contain at least 18 characters + </p> + </> + ); +} + +export default function App() { + return ( + <> + <h2>Choose password</h2> + <PasswordField /> + </> + ); +} +``` + +```js +import { createRoot } from "react-dom/client"; +import App from "./App.js"; +import "./styles.css"; + +const root1 = createRoot(document.getElementById("root1"), { + identifierPrefix: "my-first-app-", +}); +root1.render(<App />); + +const root2 = createRoot(document.getElementById("root2"), { + identifierPrefix: "my-second-app-", +}); +root2.render(<App />); +``` + +```css +#root1 { + border: 5px solid blue; + padding: 10px; + margin: 5px; +} + +#root2 { + border: 5px solid green; + padding: 10px; + margin: 5px; +} + +input { + margin: 5px; +} +``` --> diff --git a/docs/src/reference/use-imperative-handle.md b/docs/src/reference/use-imperative-handle.md new file mode 100644 index 000000000..f6c227fc2 --- /dev/null +++ b/docs/src/reference/use-imperative-handle.md @@ -0,0 +1,297 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. + +<!-- ## Overview + +<p class="intro" markdown> + +`useImperativeHandle` is a React Hook that lets you customize the handle exposed as a [ref.](/learn/manipulating-the-dom-with-refs) + +```js +useImperativeHandle(ref, createHandle, dependencies?) +``` + +</p> + +--- + +## Reference + +### `useImperativeHandle(ref, createHandle, dependencies?)` + +Call `useImperativeHandle` at the top level of your component to customize the ref handle it exposes: + +```js +import { forwardRef, useImperativeHandle } from 'react'; + +const MyInput = forwardRef(function MyInput(props, ref) { + useImperativeHandle(ref, () => { + return { + // ... your methods ... + }; + }, []); + // ... +``` + +[See more examples below.](#usage) + +#### Parameters + +- `ref`: The `ref` you received as the second argument from the [`forwardRef` render function.](/reference/react/forwardRef#render-function) + +- `createHandle`: A function that takes no arguments and returns the ref handle you want to expose. That ref handle can have any type. Usually, you will return an object with the methods you want to expose. + +- **optional** `dependencies`: The list of all reactive values referenced inside of the `createHandle` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. If a re-render resulted in a change to some dependency, or if you omitted this argument, your `createHandle` function will re-execute, and the newly created handle will be assigned to the ref. + +#### Returns + +`useImperativeHandle` returns `undefined`. + +--- + +## Usage + +### Exposing a custom ref handle to the parent component + +By default, components don't expose their DOM nodes to parent components. For example, if you want the parent component of `MyInput` to [have access](/learn/manipulating-the-dom-with-refs) to the `<input>` DOM node, you have to opt in with [`forwardRef`:](/reference/react/forwardRef) + +```js +import { forwardRef } from "react"; + +const MyInput = forwardRef(function MyInput(props, ref) { + return <input {...props} ref={ref} />; +}); +``` + +With the code above, [a ref to `MyInput` will receive the `<input>` DOM node.](/reference/react/forwardRef#exposing-a-dom-node-to-the-parent-component) However, you can expose a custom value instead. To customize the exposed handle, call `useImperativeHandle` at the top level of your component: + +```js +import { forwardRef, useImperativeHandle } from "react"; + +const MyInput = forwardRef(function MyInput(props, ref) { + useImperativeHandle( + ref, + () => { + return { + // ... your methods ... + }; + }, + [] + ); + + return <input {...props} />; +}); +``` + +Note that in the code above, the `ref` is no longer forwarded to the `<input>`. + +For example, suppose you don't want to expose the entire `<input>` DOM node, but you want to expose two of its methods: `focus` and `scrollIntoView`. To do this, keep the real browser DOM in a separate ref. Then use `useImperativeHandle` to expose a handle with only the methods that you want the parent component to call: + +```js +import { forwardRef, useRef, useImperativeHandle } from "react"; + +const MyInput = forwardRef(function MyInput(props, ref) { + const inputRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + focus() { + inputRef.current.focus(); + }, + scrollIntoView() { + inputRef.current.scrollIntoView(); + }, + }; + }, + [] + ); + + return <input {...props} ref={inputRef} />; +}); +``` + +Now, if the parent component gets a ref to `MyInput`, it will be able to call the `focus` and `scrollIntoView` methods on it. However, it will not have full access to the underlying `<input>` DOM node. + +```js +import { useRef } from "react"; +import MyInput from "./MyInput.js"; + +export default function Form() { + const ref = useRef(null); + + function handleClick() { + ref.current.focus(); + // This won't work because the DOM node isn't exposed: + // ref.current.style.opacity = 0.5; + } + + return ( + <form> + <MyInput label="Enter your name:" ref={ref} /> + <button type="button" onClick={handleClick}> + Edit + </button> + </form> + ); +} +``` + +```js +import { forwardRef, useRef, useImperativeHandle } from "react"; + +const MyInput = forwardRef(function MyInput(props, ref) { + const inputRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + focus() { + inputRef.current.focus(); + }, + scrollIntoView() { + inputRef.current.scrollIntoView(); + }, + }; + }, + [] + ); + + return <input {...props} ref={inputRef} />; +}); + +export default MyInput; +``` + +```css +input { + margin: 5px; +} +``` + +--- + +### Exposing your own imperative methods + +The methods you expose via an imperative handle don't have to match the DOM methods exactly. For example, this `Post` component exposes a `scrollAndFocusAddComment` method via an imperative handle. This lets the parent `Page` scroll the list of comments _and_ focus the input field when you click the button: + +```js +import { useRef } from "react"; +import Post from "./Post.js"; + +export default function Page() { + const postRef = useRef(null); + + function handleClick() { + postRef.current.scrollAndFocusAddComment(); + } + + return ( + <> + <button onClick={handleClick}>Write a comment</button> + <Post ref={postRef} /> + </> + ); +} +``` + +```js +import { forwardRef, useRef, useImperativeHandle } from "react"; +import CommentList from "./CommentList.js"; +import AddComment from "./AddComment.js"; + +const Post = forwardRef((props, ref) => { + const commentsRef = useRef(null); + const addCommentRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + scrollAndFocusAddComment() { + commentsRef.current.scrollToBottom(); + addCommentRef.current.focus(); + }, + }; + }, + [] + ); + + return ( + <> + <article> + <p>Welcome to my blog!</p> + </article> + <CommentList ref={commentsRef} /> + <AddComment ref={addCommentRef} /> + </> + ); +}); + +export default Post; +``` + +```js +import { forwardRef, useRef, useImperativeHandle } from "react"; + +const CommentList = forwardRef(function CommentList(props, ref) { + const divRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + scrollToBottom() { + const node = divRef.current; + node.scrollTop = node.scrollHeight; + }, + }; + }, + [] + ); + + let comments = []; + for (let i = 0; i < 50; i++) { + comments.push(<p key={i}>Comment #{i}</p>); + } + + return ( + <div className="CommentList" ref={divRef}> + {comments} + </div> + ); +}); + +export default CommentList; +``` + +```js +import { forwardRef, useRef, useImperativeHandle } from "react"; + +const AddComment = forwardRef(function AddComment(props, ref) { + return <input placeholder="Add comment..." ref={ref} />; +}); + +export default AddComment; +``` + +```css +.CommentList { + height: 100px; + overflow: scroll; + border: 1px solid black; + margin-top: 20px; + margin-bottom: 20px; +} +``` + +<Pitfall> + +**Do not overuse refs.** You should only use refs for _imperative_ behaviors that you can't express as props: for example, scrolling to a node, focusing a node, triggering an animation, selecting text, and so on. + +**If you can express something as a prop, you should not use a ref.** For example, instead of exposing an imperative handle like `{ open, close }` from a `Modal` component, it is better to take `isOpen` as a prop like `<Modal isOpen={isOpen} />`. [Effects](/learn/synchronizing-with-effects) can help you expose imperative behaviors via props. + +</Pitfall> --> diff --git a/docs/src/reference/use-insertion-effect.md b/docs/src/reference/use-insertion-effect.md new file mode 100644 index 000000000..edc9b2a93 --- /dev/null +++ b/docs/src/reference/use-insertion-effect.md @@ -0,0 +1,139 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. +<!-- +<Pitfall> + +`useInsertionEffect` is for CSS-in-JS library authors. Unless you are working on a CSS-in-JS library and need a place to inject the styles, you probably want [`useEffect`](/reference/react/useEffect) or [`useLayoutEffect`](/reference/react/useLayoutEffect) instead. + +</Pitfall> + +## Overview + +<p class="intro" markdown> + +`useInsertionEffect` is a version of [`useEffect`](/reference/react/useEffect) that fires before any DOM mutations. + +```js +useInsertionEffect(setup, dependencies?) +``` + +</p> + +--- + +## Reference + +### `useInsertionEffect(setup, dependencies?)` + +Call `useInsertionEffect` to insert the styles before any DOM mutations: + +```js +import { useInsertionEffect } from "react"; + +// Inside your CSS-in-JS library +function useCSS(rule) { + useInsertionEffect(() => { + // ... inject <style> tags here ... + }); + return rule; +} +``` + +[See more examples below.](#usage) + +#### Parameters + +- `setup`: The function with your Effect's logic. Your setup function may also optionally return a _cleanup_ function. Before your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values. Before your component is removed from the DOM, React will run your cleanup function. + +- **optional** `dependencies`: The list of all reactive values referenced inside of the `setup` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison algorithm. If you don't specify the dependencies at all, your Effect will re-run after every re-render of the component. + +#### Returns + +`useInsertionEffect` returns `undefined`. + +#### Caveats + +- Effects only run on the client. They don't run during server rendering. +- You can't update state from inside `useInsertionEffect`. +- By the time `useInsertionEffect` runs, refs are not attached yet, and DOM is not yet updated. + +--- + +## Usage + +### Injecting dynamic styles from CSS-in-JS libraries + +Traditionally, you would style React components using plain CSS. + +```js +// In your JS file: +<button className="success" /> + +// In your CSS file: +.success { color: green; } +``` + +Some teams prefer to author styles directly in JavaScript code instead of writing CSS files. This usually requires using a CSS-in-JS library or a tool. There are three common approaches to CSS-in-JS: + +1. Static extraction to CSS files with a compiler +2. Inline styles, e.g. `<div style={{ opacity: 1 }}>` +3. Runtime injection of `<style>` tags + +If you use CSS-in-JS, we recommend a combination of the first two approaches (CSS files for static styles, inline styles for dynamic styles). **We don't recommend runtime `<style>` tag injection for two reasons:** + +1. Runtime injection forces the browser to recalculate the styles a lot more often. +2. Runtime injection can be very slow if it happens at the wrong time in the React lifecycle. + +The first problem is not solvable, but `useInsertionEffect` helps you solve the second problem. + +Call `useInsertionEffect` to insert the styles before any DOM mutations: + +```js +// Inside your CSS-in-JS library +let isInserted = new Set(); +function useCSS(rule) { + useInsertionEffect(() => { + // As explained earlier, we don't recommend runtime injection of <style> tags. + // But if you have to do it, then it's important to do in useInsertionEffect. + if (!isInserted.has(rule)) { + isInserted.add(rule); + document.head.appendChild(getStyleForRule(rule)); + } + }); + return rule; +} + +function Button() { + const className = useCSS("..."); + return <div className={className} />; +} +``` + +Similarly to `useEffect`, `useInsertionEffect` does not run on the server. If you need to collect which CSS rules have been used on the server, you can do it during rendering: + +```js +let collectedRulesSet = new Set(); + +function useCSS(rule) { + if (typeof window === "undefined") { + collectedRulesSet.add(rule); + } + useInsertionEffect(() => { + // ... + }); + return rule; +} +``` + +[Read more about upgrading CSS-in-JS libraries with runtime injection to `useInsertionEffect`.](https://github.com/reactwg/react-18/discussions/110) + +<DeepDive> + +#### How is this better than injecting styles during rendering or useLayoutEffect? + +If you insert styles during rendering and React is processing a [non-blocking update,](/reference/react/useTransition#marking-a-state-update-as-a-non-blocking-transition) the browser will recalculate the styles every single frame while rendering a component tree, which can be **extremely slow.** + +`useInsertionEffect` is better than inserting styles during [`useLayoutEffect`](/reference/react/useLayoutEffect) or [`useEffect`](/reference/react/useEffect) because it ensures that by the time other Effects run in your components, the `<style>` tags have already been inserted. Otherwise, layout calculations in regular Effects would be wrong due to outdated styles. + +</DeepDive> --> diff --git a/docs/src/reference/use-layout-effect.md b/docs/src/reference/use-layout-effect.md new file mode 100644 index 000000000..ab6b44469 --- /dev/null +++ b/docs/src/reference/use-layout-effect.md @@ -0,0 +1,690 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. +<!-- +<Pitfall> + +`useLayoutEffect` can hurt performance. Prefer [`useEffect`](/reference/react/useEffect) when possible. + +</Pitfall> + +## Overview + +<p class="intro" markdown> + +`useLayoutEffect` is a version of [`useEffect`](/reference/react/useEffect) that fires before the browser repaints the screen. + +```js +useLayoutEffect(setup, dependencies?) +``` + +</p> + +--- + +## Reference + +### `useLayoutEffect(setup, dependencies?)` + +Call `useLayoutEffect` to perform the layout measurements before the browser repaints the screen: + +```js +import { useState, useRef, useLayoutEffect } from 'react'; + +function Tooltip() { + const ref = useRef(null); + const [tooltipHeight, setTooltipHeight] = useState(0); + + useLayoutEffect(() => { + const { height } = ref.current.getBoundingClientRect(); + setTooltipHeight(height); + }, []); + // ... +``` + +[See more examples below.](#usage) + +#### Parameters + +- `setup`: The function with your Effect's logic. Your setup function may also optionally return a _cleanup_ function. Before your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values. Before your component is removed from the DOM, React will run your cleanup function. + +- **optional** `dependencies`: The list of all reactive values referenced inside of the `setup` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. If you omit this argument, your Effect will re-run after every re-render of the component. + +#### Returns + +`useLayoutEffect` returns `undefined`. + +#### Caveats + +- `useLayoutEffect` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a component and move the Effect there. + +- When Strict Mode is on, React will **run one extra development-only setup+cleanup cycle** before the first real setup. This is a stress-test that ensures that your cleanup logic "mirrors" your setup logic and that it stops or undoes whatever the setup is doing. If this causes a problem, [implement the cleanup function.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +- If some of your dependencies are objects or functions defined inside the component, there is a risk that they will **cause the Effect to re-run more often than needed.** To fix this, remove unnecessary [object](/reference/react/useEffect#removing-unnecessary-object-dependencies) and [function](/reference/react/useEffect#removing-unnecessary-function-dependencies) dependencies. You can also [extract state updates](/reference/react/useEffect#updating-state-based-on-previous-state-from-an-effect) and [non-reactive logic](/reference/react/useEffect#reading-the-latest-props-and-state-from-an-effect) outside of your Effect. + +- Effects **only run on the client.** They don't run during server rendering. + +- The code inside `useLayoutEffect` and all state updates scheduled from it **block the browser from repainting the screen.** When used excessively, this makes your app slow. When possible, prefer [`useEffect`.](/reference/react/useEffect) + +--- + +## Usage + +### Measuring layout before the browser repaints the screen + +Most components don't need to know their position and size on the screen to decide what to render. They only return some JSX. Then the browser calculates their _layout_ (position and size) and repaints the screen. + +Sometimes, that's not enough. Imagine a tooltip that appears next to some element on hover. If there's enough space, the tooltip should appear above the element, but if it doesn't fit, it should appear below. In order to render the tooltip at the right final position, you need to know its height (i.e. whether it fits at the top). + +To do this, you need to render in two passes: + +1. Render the tooltip anywhere (even with a wrong position). +2. Measure its height and decide where to place the tooltip. +3. Render the tooltip _again_ in the correct place. + +**All of this needs to happen before the browser repaints the screen.** You don't want the user to see the tooltip moving. Call `useLayoutEffect` to perform the layout measurements before the browser repaints the screen: + +```js +function Tooltip() { + const ref = useRef(null); + const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet + + useLayoutEffect(() => { + const { height } = ref.current.getBoundingClientRect(); + setTooltipHeight(height); // Re-render now that you know the real height + }, []); + + // ...use tooltipHeight in the rendering logic below... +} +``` + +Here's how this works step by step: + +1. `Tooltip` renders with the initial `tooltipHeight = 0` (so the tooltip may be wrongly positioned). +2. React places it in the DOM and runs the code in `useLayoutEffect`. +3. Your `useLayoutEffect` [measures the height](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) of the tooltip content and triggers an immediate re-render. +4. `Tooltip` renders again with the real `tooltipHeight` (so the tooltip is correctly positioned). +5. React updates it in the DOM, and the browser finally displays the tooltip. + +Hover over the buttons below and see how the tooltip adjusts its position depending on whether it fits: + +```js +import ButtonWithTooltip from "./ButtonWithTooltip.js"; + +export default function App() { + return ( + <div> + <ButtonWithTooltip + tooltipContent={ + <div> + This tooltip does not fit above the button. + <br /> + This is why it's displayed below instead! + </div> + } + > + Hover over me (tooltip above) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + </div> + ); +} +``` + +```js +import { useState, useRef } from "react"; +import Tooltip from "./Tooltip.js"; + +export default function ButtonWithTooltip({ tooltipContent, ...rest }) { + const [targetRect, setTargetRect] = useState(null); + const buttonRef = useRef(null); + return ( + <> + <button + {...rest} + ref={buttonRef} + onPointerEnter={() => { + const rect = buttonRef.current.getBoundingClientRect(); + setTargetRect({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }); + }} + onPointerLeave={() => { + setTargetRect(null); + }} + /> + {targetRect !== null && ( + <Tooltip targetRect={targetRect}>{tooltipContent}</Tooltip> + )} + </> + ); +} +``` + +```js +import { useRef, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import TooltipContainer from "./TooltipContainer.js"; + +export default function Tooltip({ children, targetRect }) { + const ref = useRef(null); + const [tooltipHeight, setTooltipHeight] = useState(0); + + useLayoutEffect(() => { + const { height } = ref.current.getBoundingClientRect(); + setTooltipHeight(height); + console.log("Measured tooltip height: " + height); + }, []); + + let tooltipX = 0; + let tooltipY = 0; + if (targetRect !== null) { + tooltipX = targetRect.left; + tooltipY = targetRect.top - tooltipHeight; + if (tooltipY < 0) { + // It doesn't fit above, so place below. + tooltipY = targetRect.bottom; + } + } + + return createPortal( + <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}> + {children} + </TooltipContainer>, + document.body + ); +} +``` + +```js +export default function TooltipContainer({ children, x, y, contentRef }) { + return ( + <div + style={{ + position: "absolute", + pointerEvents: "none", + left: 0, + top: 0, + transform: `translate3d(${x}px, ${y}px, 0)`, + }} + > + <div ref={contentRef} className="tooltip"> + {children} + </div> + </div> + ); +} +``` + +```css +.tooltip { + color: white; + background: #222; + border-radius: 4px; + padding: 4px; +} +``` + +Notice that even though the `Tooltip` component has to render in two passes (first, with `tooltipHeight` initialized to `0` and then with the real measured height), you only see the final result. This is why you need `useLayoutEffect` instead of [`useEffect`](/reference/react/useEffect) for this example. Let's look at the difference in detail below. + +<Recipes titleText="useLayoutEffect vs useEffect" titleId="examples"> + +#### `useLayoutEffect` blocks the browser from repainting + +React guarantees that the code inside `useLayoutEffect` and any state updates scheduled inside it will be processed **before the browser repaints the screen.** This lets you render the tooltip, measure it, and re-render the tooltip again without the user noticing the first extra render. In other words, `useLayoutEffect` blocks the browser from painting. + +```js +import ButtonWithTooltip from "./ButtonWithTooltip.js"; + +export default function App() { + return ( + <div> + <ButtonWithTooltip + tooltipContent={ + <div> + This tooltip does not fit above the button. + <br /> + This is why it's displayed below instead! + </div> + } + > + Hover over me (tooltip above) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + </div> + ); +} +``` + +```js +import { useState, useRef } from "react"; +import Tooltip from "./Tooltip.js"; + +export default function ButtonWithTooltip({ tooltipContent, ...rest }) { + const [targetRect, setTargetRect] = useState(null); + const buttonRef = useRef(null); + return ( + <> + <button + {...rest} + ref={buttonRef} + onPointerEnter={() => { + const rect = buttonRef.current.getBoundingClientRect(); + setTargetRect({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }); + }} + onPointerLeave={() => { + setTargetRect(null); + }} + /> + {targetRect !== null && ( + <Tooltip targetRect={targetRect}>{tooltipContent}</Tooltip> + )} + </> + ); +} +``` + +```js +import { useRef, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import TooltipContainer from "./TooltipContainer.js"; + +export default function Tooltip({ children, targetRect }) { + const ref = useRef(null); + const [tooltipHeight, setTooltipHeight] = useState(0); + + useLayoutEffect(() => { + const { height } = ref.current.getBoundingClientRect(); + setTooltipHeight(height); + }, []); + + let tooltipX = 0; + let tooltipY = 0; + if (targetRect !== null) { + tooltipX = targetRect.left; + tooltipY = targetRect.top - tooltipHeight; + if (tooltipY < 0) { + // It doesn't fit above, so place below. + tooltipY = targetRect.bottom; + } + } + + return createPortal( + <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}> + {children} + </TooltipContainer>, + document.body + ); +} +``` + +```js +export default function TooltipContainer({ children, x, y, contentRef }) { + return ( + <div + style={{ + position: "absolute", + pointerEvents: "none", + left: 0, + top: 0, + transform: `translate3d(${x}px, ${y}px, 0)`, + }} + > + <div ref={contentRef} className="tooltip"> + {children} + </div> + </div> + ); +} +``` + +```css +.tooltip { + color: white; + background: #222; + border-radius: 4px; + padding: 4px; +} +``` + +#### `useEffect` does not block the browser + +Here is the same example, but with [`useEffect`](/reference/react/useEffect) instead of `useLayoutEffect`. If you're on a slower device, you might notice that sometimes the tooltip "flickers" and you briefly see its initial position before the corrected position. + +```js +import ButtonWithTooltip from "./ButtonWithTooltip.js"; + +export default function App() { + return ( + <div> + <ButtonWithTooltip + tooltipContent={ + <div> + This tooltip does not fit above the button. + <br /> + This is why it's displayed below instead! + </div> + } + > + Hover over me (tooltip above) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + </div> + ); +} +``` + +```js +import { useState, useRef } from "react"; +import Tooltip from "./Tooltip.js"; + +export default function ButtonWithTooltip({ tooltipContent, ...rest }) { + const [targetRect, setTargetRect] = useState(null); + const buttonRef = useRef(null); + return ( + <> + <button + {...rest} + ref={buttonRef} + onPointerEnter={() => { + const rect = buttonRef.current.getBoundingClientRect(); + setTargetRect({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }); + }} + onPointerLeave={() => { + setTargetRect(null); + }} + /> + {targetRect !== null && ( + <Tooltip targetRect={targetRect}>{tooltipContent}</Tooltip> + )} + </> + ); +} +``` + +```js +import { useRef, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import TooltipContainer from "./TooltipContainer.js"; + +export default function Tooltip({ children, targetRect }) { + const ref = useRef(null); + const [tooltipHeight, setTooltipHeight] = useState(0); + + useEffect(() => { + const { height } = ref.current.getBoundingClientRect(); + setTooltipHeight(height); + }, []); + + let tooltipX = 0; + let tooltipY = 0; + if (targetRect !== null) { + tooltipX = targetRect.left; + tooltipY = targetRect.top - tooltipHeight; + if (tooltipY < 0) { + // It doesn't fit above, so place below. + tooltipY = targetRect.bottom; + } + } + + return createPortal( + <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}> + {children} + </TooltipContainer>, + document.body + ); +} +``` + +```js +export default function TooltipContainer({ children, x, y, contentRef }) { + return ( + <div + style={{ + position: "absolute", + pointerEvents: "none", + left: 0, + top: 0, + transform: `translate3d(${x}px, ${y}px, 0)`, + }} + > + <div ref={contentRef} className="tooltip"> + {children} + </div> + </div> + ); +} +``` + +```css +.tooltip { + color: white; + background: #222; + border-radius: 4px; + padding: 4px; +} +``` + +To make the bug easier to reproduce, this version adds an artificial delay during rendering. React will let the browser paint the screen before it processes the state update inside `useEffect`. As a result, the tooltip flickers: + +```js +import ButtonWithTooltip from "./ButtonWithTooltip.js"; + +export default function App() { + return ( + <div> + <ButtonWithTooltip + tooltipContent={ + <div> + This tooltip does not fit above the button. + <br /> + This is why it's displayed below instead! + </div> + } + > + Hover over me (tooltip above) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + <div style={{ height: 50 }} /> + <ButtonWithTooltip + tooltipContent={<div>This tooltip fits above the button</div>} + > + Hover over me (tooltip below) + </ButtonWithTooltip> + </div> + ); +} +``` + +```js +import { useState, useRef } from "react"; +import Tooltip from "./Tooltip.js"; + +export default function ButtonWithTooltip({ tooltipContent, ...rest }) { + const [targetRect, setTargetRect] = useState(null); + const buttonRef = useRef(null); + return ( + <> + <button + {...rest} + ref={buttonRef} + onPointerEnter={() => { + const rect = buttonRef.current.getBoundingClientRect(); + setTargetRect({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }); + }} + onPointerLeave={() => { + setTargetRect(null); + }} + /> + {targetRect !== null && ( + <Tooltip targetRect={targetRect}>{tooltipContent}</Tooltip> + )} + </> + ); +} +``` + +```js +import { useRef, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import TooltipContainer from "./TooltipContainer.js"; + +export default function Tooltip({ children, targetRect }) { + const ref = useRef(null); + const [tooltipHeight, setTooltipHeight] = useState(0); + + // This artificially slows down rendering + let now = performance.now(); + while (performance.now() - now < 100) { + // Do nothing for a bit... + } + + useEffect(() => { + const { height } = ref.current.getBoundingClientRect(); + setTooltipHeight(height); + }, []); + + let tooltipX = 0; + let tooltipY = 0; + if (targetRect !== null) { + tooltipX = targetRect.left; + tooltipY = targetRect.top - tooltipHeight; + if (tooltipY < 0) { + // It doesn't fit above, so place below. + tooltipY = targetRect.bottom; + } + } + + return createPortal( + <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}> + {children} + </TooltipContainer>, + document.body + ); +} +``` + +```js +export default function TooltipContainer({ children, x, y, contentRef }) { + return ( + <div + style={{ + position: "absolute", + pointerEvents: "none", + left: 0, + top: 0, + transform: `translate3d(${x}px, ${y}px, 0)`, + }} + > + <div ref={contentRef} className="tooltip"> + {children} + </div> + </div> + ); +} +``` + +```css +.tooltip { + color: white; + background: #222; + border-radius: 4px; + padding: 4px; +} +``` + +Edit this example to `useLayoutEffect` and observe that it blocks the paint even if rendering is slowed down. + +</Recipes> + +<Note> + +Rendering in two passes and blocking the browser hurts performance. Try to avoid this when you can. + +</Note> + +--- + +## Troubleshooting + +### I'm getting an error: "`useLayoutEffect` does nothing on the server" + +The purpose of `useLayoutEffect` is to let your component [use layout information for rendering:](#measuring-layout-before-the-browser-repaints-the-screen) + +1. Render the initial content. +2. Measure the layout _before the browser repaints the screen._ +3. Render the final content using the layout information you've read. + +When you or your framework uses [server rendering](/reference/react-dom/server), your React app renders to HTML on the server for the initial render. This lets you show the initial HTML before the JavaScript code loads. + +The problem is that on the server, there is no layout information. + +In the [earlier example](#measuring-layout-before-the-browser-repaints-the-screen), the `useLayoutEffect` call in the `Tooltip` component lets it position itself correctly (either above or below content) depending on the content height. If you tried to render `Tooltip` as a part of the initial server HTML, this would be impossible to determine. On the server, there is no layout yet! So, even if you rendered it on the server, its position would "jump" on the client after the JavaScript loads and runs. + +Usually, components that rely on layout information don't need to render on the server anyway. For example, it probably doesn't make sense to show a `Tooltip` during the initial render. It is triggered by a client interaction. + +However, if you're running into this problem, you have a few different options: + +- Replace `useLayoutEffect` with [`useEffect`.](/reference/react/useEffect) This tells React that it's okay to display the initial render result without blocking the paint (because the original HTML will become visible before your Effect runs). + +- Alternatively, [mark your component as client-only.](/reference/react/Suspense#providing-a-fallback-for-server-errors-and-server-only-content) This tells React to replace its content up to the closest [`<Suspense>`](/reference/react/Suspense) boundary with a loading fallback (for example, a spinner or a glimmer) during server rendering. + +- Alternatively, you can render a component with `useLayoutEffect` only after hydration. Keep a boolean `isMounted` state that's initialized to `false`, and set it to `true` inside a `useEffect` call. Your rendering logic can then be like `return isMounted ? <RealContent /> : <FallbackContent />`. On the server and during the hydration, the user will see `FallbackContent` which should not call `useLayoutEffect`. Then React will replace it with `RealContent` which runs on the client only and can include `useLayoutEffect` calls. + +- If you synchronize your component with an external data store and rely on `useLayoutEffect` for different reasons than measuring layout, consider [`useSyncExternalStore`](/reference/react/useSyncExternalStore) instead which [supports server rendering.](/reference/react/useSyncExternalStore#adding-support-for-server-rendering) --> diff --git a/docs/src/reference/use-location.md b/docs/src/reference/use-location.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/use-memo.md b/docs/src/reference/use-memo.md new file mode 100644 index 000000000..6bbd12a10 --- /dev/null +++ b/docs/src/reference/use-memo.md @@ -0,0 +1,1284 @@ +## Overview + +<p class="intro" markdown> + +`useMemo` is a React Hook that lets you cache the result of a calculation between re-renders. + +```js +const cachedValue = useMemo(calculateValue, dependencies); +``` + +</p> + +--- + +## Reference + +### `useMemo(calculateValue, dependencies)` + +Call `useMemo` at the top level of your component to cache a calculation between re-renders: + +```js +import { useMemo } from "react"; + +function TodoList({ todos, tab }) { + const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); + // ... +} +``` + +[See more examples below.](#usage) + +#### Parameters + +- `calculateValue`: The function calculating the value that you want to cache. It should be pure, should take no arguments, and should return a value of any type. React will call your function during the initial render. On next renders, React will return the same value again if the `dependencies` have not changed since the last render. Otherwise, it will call `calculateValue`, return its result, and store it so it can be reused later. + +- `dependencies`: The list of all reactive values referenced inside of the `calculateValue` code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is [configured for React](/learn/editor-setup#linting), it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`. React will compare each dependency with its previous value using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. + +#### Returns + +On the initial render, `useMemo` returns the result of calling `calculateValue` with no arguments. + +During next renders, it will either return an already stored value from the last render (if the dependencies haven't changed), or call `calculateValue` again, and return the result that `calculateValue` has returned. + +#### Caveats + +- `useMemo` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. +- In Strict Mode, React will **call your calculation function twice** in order to [help you find accidental impurities.](#my-calculation-runs-twice-on-every-re-render) This is development-only behavior and does not affect production. If your calculation function is pure (as it should be), this should not affect your logic. The result from one of the calls will be ignored. +- React **will not throw away the cached value unless there is a specific reason to do that.** For example, in development, React throws away the cache when you edit the file of your component. Both in development and in production, React will throw away the cache if your component suspends during the initial mount. In the future, React may add more features that take advantage of throwing away the cache--for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport. This should be fine if you rely on `useMemo` solely as a performance optimization. Otherwise, a [state variable](/reference/react/useState#avoiding-recreating-the-initial-state) or a [ref](/reference/react/useRef#avoiding-recreating-the-ref-contents) may be more appropriate. + +<Note> + +Caching return values like this is also known as [_memoization_,](https://en.wikipedia.org/wiki/Memoization) which is why this Hook is called `useMemo`. + +</Note> + +--- + +## Usage + +### Skipping expensive recalculations + +To cache a calculation between re-renders, wrap it in a `useMemo` call at the top level of your component: + +```js +import { useMemo } from "react"; + +function TodoList({ todos, tab, theme }) { + const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); + // ... +} +``` + +You need to pass two things to `useMemo`: + +1. A <CodeStep step={1}>calculation function</CodeStep> that takes no arguments, like `() =>`, and returns what you wanted to calculate. +2. A <CodeStep step={2}>list of dependencies</CodeStep> including every value within your component that's used inside your calculation. + +On the initial render, the <CodeStep step={3}>value</CodeStep> you'll get from `useMemo` will be the result of calling your <CodeStep step={1}>calculation</CodeStep>. + +On every subsequent render, React will compare the <CodeStep step={2}>dependencies</CodeStep> with the dependencies you passed during the last render. If none of the dependencies have changed (compared with [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)), `useMemo` will return the value you already calculated before. Otherwise, React will re-run your calculation and return the new value. + +In other words, `useMemo` caches a calculation result between re-renders until its dependencies change. + +**Let's walk through an example to see when this is useful.** + +By default, React will re-run the entire body of your component every time that it re-renders. For example, if this `TodoList` updates its state or receives new props from its parent, the `filterTodos` function will re-run: + +```js +function TodoList({ todos, tab, theme }) { + const visibleTodos = filterTodos(todos, tab); + // ... +} +``` + +Usually, this isn't a problem because most calculations are very fast. However, if you're filtering or transforming a large array, or doing some expensive computation, you might want to skip doing it again if data hasn't changed. If both `todos` and `tab` are the same as they were during the last render, wrapping the calculation in `useMemo` like earlier lets you reuse `visibleTodos` you've already calculated before. + +This type of caching is called _[memoization.](https://en.wikipedia.org/wiki/Memoization)_ + +<Note> + +**You should only rely on `useMemo` as a performance optimization.** If your code doesn't work without it, find the underlying problem and fix it first. Then you may add `useMemo` to improve performance. + +</Note> + +<DeepDive> + +#### How to tell if a calculation is expensive? + +In general, unless you're creating or looping over thousands of objects, it's probably not expensive. If you want to get more confidence, you can add a console log to measure the time spent in a piece of code: + +```js +console.time("filter array"); +const visibleTodos = filterTodos(todos, tab); +console.timeEnd("filter array"); +``` + +Perform the interaction you're measuring (for example, typing into the input). You will then see logs like `filter array: 0.15ms` in your console. If the overall logged time adds up to a significant amount (say, `1ms` or more), it might make sense to memoize that calculation. As an experiment, you can then wrap the calculation in `useMemo` to verify whether the total logged time has decreased for that interaction or not: + +```js +console.time("filter array"); +const visibleTodos = useMemo(() => { + return filterTodos(todos, tab); // Skipped if todos and tab haven't changed +}, [todos, tab]); +console.timeEnd("filter array"); +``` + +`useMemo` won't make the _first_ render faster. It only helps you skip unnecessary work on updates. + +Keep in mind that your machine is probably faster than your users' so it's a good idea to test the performance with an artificial slowdown. For example, Chrome offers a [CPU Throttling](https://developer.chrome.com/blog/new-in-devtools-61/#throttling) option for this. + +Also note that measuring performance in development will not give you the most accurate results. (For example, when [Strict Mode](/reference/react/StrictMode) is on, you will see each component render twice rather than once.) To get the most accurate timings, build your app for production and test it on a device like your users have. + +</DeepDive> + +<DeepDive> + +#### Should you add useMemo everywhere? + +If your app is like this site, and most interactions are coarse (like replacing a page or an entire section), memoization is usually unnecessary. On the other hand, if your app is more like a drawing editor, and most interactions are granular (like moving shapes), then you might find memoization very helpful. + +Optimizing with `useMemo` is only valuable in a few cases: + +- The calculation you're putting in `useMemo` is noticeably slow, and its dependencies rarely change. +- You pass it as a prop to a component wrapped in [`memo`.](/reference/react/memo) You want to skip re-rendering if the value hasn't changed. Memoization lets your component re-render only when dependencies aren't the same. +- The value you're passing is later used as a dependency of some Hook. For example, maybe another `useMemo` calculation value depends on it. Or maybe you are depending on this value from [`useEffect.`](/reference/react/useEffect) + +There is no benefit to wrapping a calculation in `useMemo` in other cases. There is no significant harm to doing that either, so some teams choose to not think about individual cases, and memoize as much as possible. The downside of this approach is that code becomes less readable. Also, not all memoization is effective: a single value that's "always new" is enough to break memoization for an entire component. + +**In practice, you can make a lot of memoization unnecessary by following a few principles:** + +1. When a component visually wraps other components, let it [accept JSX as children.](/learn/passing-props-to-a-component#passing-jsx-as-children) This way, when the wrapper component updates its own state, React knows that its children don't need to re-render. +1. Prefer local state and don't [lift state up](/learn/sharing-state-between-components) any further than necessary. For example, don't keep transient state like forms and whether an item is hovered at the top of your tree or in a global state library. +1. Keep your [rendering logic pure.](/learn/keeping-components-pure) If re-rendering a component causes a problem or produces some noticeable visual artifact, it's a bug in your component! Fix the bug instead of adding memoization. +1. Avoid [unnecessary Effects that update state.](/learn/you-might-not-need-an-effect) Most performance problems in React apps are caused by chains of updates originating from Effects that cause your components to render over and over. +1. Try to [remove unnecessary dependencies from your Effects.](/learn/removing-effect-dependencies) For example, instead of memoization, it's often simpler to move some object or a function inside an Effect or outside the component. + +If a specific interaction still feels laggy, [use the React Developer Tools profiler](https://legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html) to see which components would benefit the most from memoization, and add memoization where needed. These principles make your components easier to debug and understand, so it's good to follow them in any case. In the long term, we're researching [doing granular memoization automatically](https://www.youtube.com/watch?v=lGEMwh32soc) to solve this once and for all. + +</DeepDive> + +<Recipes titleText="The difference between useMemo and calculating a value directly" titleId="examples-recalculation"> + +#### Skipping recalculation with `useMemo` + +In this example, the `filterTodos` implementation is **artificially slowed down** so that you can see what happens when some JavaScript function you're calling during rendering is genuinely slow. Try switching the tabs and toggling the theme. + +Switching the tabs feels slow because it forces the slowed down `filterTodos` to re-execute. That's expected because the `tab` has changed, and so the entire calculation _needs_ to re-run. (If you're curious why it runs twice, it's explained [here.](#my-calculation-runs-twice-on-every-re-render)) + +Toggle the theme. **Thanks to `useMemo`, it's fast despite the artificial slowdown!** The slow `filterTodos` call was skipped because both `todos` and `tab` (which you pass as dependencies to `useMemo`) haven't changed since the last render. + +```js +import { useState } from "react"; +import { createTodos } from "./utils.js"; +import TodoList from "./TodoList.js"; + +const todos = createTodos(); + +export default function App() { + const [tab, setTab] = useState("all"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <button onClick={() => setTab("all")}>All</button> + <button onClick={() => setTab("active")}>Active</button> + <button onClick={() => setTab("completed")}>Completed</button> + <br /> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <TodoList + todos={todos} + tab={tab} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import { useMemo } from "react"; +import { filterTodos } from "./utils.js"; + +export default function TodoList({ todos, theme, tab }) { + const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); + return ( + <div className={theme}> + <p> + <b> + Note: <code>filterTodos</code> is artificially slowed down! + </b> + </p> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + </div> + ); +} +``` + +```js +export function createTodos() { + const todos = []; + for (let i = 0; i < 50; i++) { + todos.push({ + id: i, + text: "Todo " + (i + 1), + completed: Math.random() > 0.5, + }); + } + return todos; +} + +export function filterTodos(todos, tab) { + console.log( + "[ARTIFICIALLY SLOW] Filtering " + + todos.length + + ' todos for "' + + tab + + '" tab.' + ); + let startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Do nothing for 500 ms to emulate extremely slow code + } + + return todos.filter((todo) => { + if (tab === "all") { + return true; + } else if (tab === "active") { + return !todo.completed; + } else if (tab === "completed") { + return todo.completed; + } + }); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +#### Always recalculating a value + +In this example, the `filterTodos` implementation is also **artificially slowed down** so that you can see what happens when some JavaScript function you're calling during rendering is genuinely slow. Try switching the tabs and toggling the theme. + +Unlike in the previous example, toggling the theme is also slow now! This is because **there is no `useMemo` call in this version,** so the artificially slowed down `filterTodos` gets called on every re-render. It is called even if only `theme` has changed. + +```js +import { useState } from "react"; +import { createTodos } from "./utils.js"; +import TodoList from "./TodoList.js"; + +const todos = createTodos(); + +export default function App() { + const [tab, setTab] = useState("all"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <button onClick={() => setTab("all")}>All</button> + <button onClick={() => setTab("active")}>Active</button> + <button onClick={() => setTab("completed")}>Completed</button> + <br /> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <TodoList + todos={todos} + tab={tab} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import { filterTodos } from "./utils.js"; + +export default function TodoList({ todos, theme, tab }) { + const visibleTodos = filterTodos(todos, tab); + return ( + <div className={theme}> + <ul> + <p> + <b> + Note: <code>filterTodos</code> is artificially slowed + down! + </b> + </p> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + </div> + ); +} +``` + +```js +export function createTodos() { + const todos = []; + for (let i = 0; i < 50; i++) { + todos.push({ + id: i, + text: "Todo " + (i + 1), + completed: Math.random() > 0.5, + }); + } + return todos; +} + +export function filterTodos(todos, tab) { + console.log( + "[ARTIFICIALLY SLOW] Filtering " + + todos.length + + ' todos for "' + + tab + + '" tab.' + ); + let startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Do nothing for 500 ms to emulate extremely slow code + } + + return todos.filter((todo) => { + if (tab === "all") { + return true; + } else if (tab === "active") { + return !todo.completed; + } else if (tab === "completed") { + return todo.completed; + } + }); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +However, here is the same code **with the artificial slowdown removed.** Does the lack of `useMemo` feel noticeable or not? + +```js +import { useState } from "react"; +import { createTodos } from "./utils.js"; +import TodoList from "./TodoList.js"; + +const todos = createTodos(); + +export default function App() { + const [tab, setTab] = useState("all"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <button onClick={() => setTab("all")}>All</button> + <button onClick={() => setTab("active")}>Active</button> + <button onClick={() => setTab("completed")}>Completed</button> + <br /> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <TodoList + todos={todos} + tab={tab} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import { filterTodos } from "./utils.js"; + +export default function TodoList({ todos, theme, tab }) { + const visibleTodos = filterTodos(todos, tab); + return ( + <div className={theme}> + <ul> + {visibleTodos.map((todo) => ( + <li key={todo.id}> + {todo.completed ? <s>{todo.text}</s> : todo.text} + </li> + ))} + </ul> + </div> + ); +} +``` + +```js +export function createTodos() { + const todos = []; + for (let i = 0; i < 50; i++) { + todos.push({ + id: i, + text: "Todo " + (i + 1), + completed: Math.random() > 0.5, + }); + } + return todos; +} + +export function filterTodos(todos, tab) { + console.log("Filtering " + todos.length + ' todos for "' + tab + '" tab.'); + + return todos.filter((todo) => { + if (tab === "all") { + return true; + } else if (tab === "active") { + return !todo.completed; + } else if (tab === "completed") { + return todo.completed; + } + }); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +Quite often, code without memoization works fine. If your interactions are fast enough, you might not need memoization. + +You can try increasing the number of todo items in `utils.js` and see how the behavior changes. This particular calculation wasn't very expensive to begin with, but if the number of todos grows significantly, most of the overhead will be in re-rendering rather than in the filtering. Keep reading below to see how you can optimize re-rendering with `useMemo`. + +</Recipes> + +--- + +### Skipping re-rendering of components + +In some cases, `useMemo` can also help you optimize performance of re-rendering child components. To illustrate this, let's say this `TodoList` component passes the `visibleTodos` as a prop to the child `List` component: + +```js +export default function TodoList({ todos, tab, theme }) { + // ... + return ( + <div className={theme}> + <List items={visibleTodos} /> + </div> + ); +} +``` + +You've noticed that toggling the `theme` prop freezes the app for a moment, but if you remove `<List />` from your JSX, it feels fast. This tells you that it's worth trying to optimize the `List` component. + +**By default, when a component re-renders, React re-renders all of its children recursively.** This is why, when `TodoList` re-renders with a different `theme`, the `List` component _also_ re-renders. This is fine for components that don't require much calculation to re-render. But if you've verified that a re-render is slow, you can tell `List` to skip re-rendering when its props are the same as on last render by wrapping it in [`memo`:](/reference/react/memo) + +```js +import { memo } from "react"; + +const List = memo(function List({ items }) { + // ... +}); +``` + +**With this change, `List` will skip re-rendering if all of its props are the _same_ as on the last render.** This is where caching the calculation becomes important! Imagine that you calculated `visibleTodos` without `useMemo`: + +```js +export default function TodoList({ todos, tab, theme }) { + // Every time the theme changes, this will be a different array... + const visibleTodos = filterTodos(todos, tab); + return ( + <div className={theme}> + {/* ... so List's props will never be the same, and it will re-render every time */} + <List items={visibleTodos} /> + </div> + ); +} +``` + +**In the above example, the `filterTodos` function always creates a _different_ array,** similar to how the `{}` object literal always creates a new object. Normally, this wouldn't be a problem, but it means that `List` props will never be the same, and your [`memo`](/reference/react/memo) optimization won't work. This is where `useMemo` comes in handy: + +```js +export default function TodoList({ todos, tab, theme }) { + // Tell React to cache your calculation between re-renders... + const visibleTodos = useMemo( + () => filterTodos(todos, tab), + [todos, tab] // ...so as long as these dependencies don't change... + ); + return ( + <div className={theme}> + {/* ...List will receive the same props and can skip re-rendering */} + <List items={visibleTodos} /> + </div> + ); +} +``` + +**By wrapping the `visibleTodos` calculation in `useMemo`, you ensure that it has the _same_ value between the re-renders** (until dependencies change). You don't _have to_ wrap a calculation in `useMemo` unless you do it for some specific reason. In this example, the reason is that you pass it to a component wrapped in [`memo`,](/reference/react/memo) and this lets it skip re-rendering. There are a few other reasons to add `useMemo` which are described further on this page. + +<DeepDive> + +#### Memoizing individual JSX nodes + +Instead of wrapping `List` in [`memo`](/reference/react/memo), you could wrap the `<List />` JSX node itself in `useMemo`: + +```js +export default function TodoList({ todos, tab, theme }) { + const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); + const children = useMemo( + () => <List items={visibleTodos} />, + [visibleTodos] + ); + return <div className={theme}>{children}</div>; +} +``` + +The behavior would be the same. If the `visibleTodos` haven't changed, `List` won't be re-rendered. + +A JSX node like `<List items={visibleTodos} />` is an object like `{ type: List, props: { items: visibleTodos } }`. Creating this object is very cheap, but React doesn't know whether its contents is the same as last time or not. This is why by default, React will re-render the `List` component. + +However, if React sees the same exact JSX as during the previous render, it won't try to re-render your component. This is because JSX nodes are [immutable.](https://en.wikipedia.org/wiki/Immutable_object) A JSX node object could not have changed over time, so React knows it's safe to skip a re-render. However, for this to work, the node has to _actually be the same object_, not merely look the same in code. This is what `useMemo` does in this example. + +Manually wrapping JSX nodes into `useMemo` is not convenient. For example, you can't do this conditionally. This is usually why you would wrap components with [`memo`](/reference/react/memo) instead of wrapping JSX nodes. + +</DeepDive> + +<Recipes titleText="The difference between skipping re-renders and always re-rendering" titleId="examples-rerendering"> + +#### Skipping re-rendering with `useMemo` and `memo` + +In this example, the `List` component is **artificially slowed down** so that you can see what happens when a React component you're rendering is genuinely slow. Try switching the tabs and toggling the theme. + +Switching the tabs feels slow because it forces the slowed down `List` to re-render. That's expected because the `tab` has changed, and so you need to reflect the user's new choice on the screen. + +Next, try toggling the theme. **Thanks to `useMemo` together with [`memo`](/reference/react/memo), it’s fast despite the artificial slowdown!** The `List` skipped re-rendering because the `visibleItems` array has not changed since the last render. The `visibleItems` array has not changed because both `todos` and `tab` (which you pass as dependencies to `useMemo`) haven't changed since the last render. + +```js +import { useState } from "react"; +import { createTodos } from "./utils.js"; +import TodoList from "./TodoList.js"; + +const todos = createTodos(); + +export default function App() { + const [tab, setTab] = useState("all"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <button onClick={() => setTab("all")}>All</button> + <button onClick={() => setTab("active")}>Active</button> + <button onClick={() => setTab("completed")}>Completed</button> + <br /> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <TodoList + todos={todos} + tab={tab} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import { useMemo } from "react"; +import List from "./List.js"; +import { filterTodos } from "./utils.js"; + +export default function TodoList({ todos, theme, tab }) { + const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); + return ( + <div className={theme}> + <p> + <b> + Note: <code>List</code> is artificially slowed down! + </b> + </p> + <List items={visibleTodos} /> + </div> + ); +} +``` + +```js +import { memo } from "react"; + +const List = memo(function List({ items }) { + console.log( + "[ARTIFICIALLY SLOW] Rendering <List /> with " + items.length + " items" + ); + let startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Do nothing for 500 ms to emulate extremely slow code + } + + return ( + <ul> + {items.map((item) => ( + <li key={item.id}> + {item.completed ? <s>{item.text}</s> : item.text} + </li> + ))} + </ul> + ); +}); + +export default List; +``` + +```js +export function createTodos() { + const todos = []; + for (let i = 0; i < 50; i++) { + todos.push({ + id: i, + text: "Todo " + (i + 1), + completed: Math.random() > 0.5, + }); + } + return todos; +} + +export function filterTodos(todos, tab) { + return todos.filter((todo) => { + if (tab === "all") { + return true; + } else if (tab === "active") { + return !todo.completed; + } else if (tab === "completed") { + return todo.completed; + } + }); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +#### Always re-rendering a component + +In this example, the `List` implementation is also **artificially slowed down** so that you can see what happens when some React component you're rendering is genuinely slow. Try switching the tabs and toggling the theme. + +Unlike in the previous example, toggling the theme is also slow now! This is because **there is no `useMemo` call in this version,** so the `visibleTodos` is always a different array, and the slowed down `List` component can't skip re-rendering. + +```js +import { useState } from "react"; +import { createTodos } from "./utils.js"; +import TodoList from "./TodoList.js"; + +const todos = createTodos(); + +export default function App() { + const [tab, setTab] = useState("all"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <button onClick={() => setTab("all")}>All</button> + <button onClick={() => setTab("active")}>Active</button> + <button onClick={() => setTab("completed")}>Completed</button> + <br /> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <TodoList + todos={todos} + tab={tab} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import List from "./List.js"; +import { filterTodos } from "./utils.js"; + +export default function TodoList({ todos, theme, tab }) { + const visibleTodos = filterTodos(todos, tab); + return ( + <div className={theme}> + <p> + <b> + Note: <code>List</code> is artificially slowed down! + </b> + </p> + <List items={visibleTodos} /> + </div> + ); +} +``` + +```js +import { memo } from "react"; + +const List = memo(function List({ items }) { + console.log( + "[ARTIFICIALLY SLOW] Rendering <List /> with " + items.length + " items" + ); + let startTime = performance.now(); + while (performance.now() - startTime < 500) { + // Do nothing for 500 ms to emulate extremely slow code + } + + return ( + <ul> + {items.map((item) => ( + <li key={item.id}> + {item.completed ? <s>{item.text}</s> : item.text} + </li> + ))} + </ul> + ); +}); + +export default List; +``` + +```js +export function createTodos() { + const todos = []; + for (let i = 0; i < 50; i++) { + todos.push({ + id: i, + text: "Todo " + (i + 1), + completed: Math.random() > 0.5, + }); + } + return todos; +} + +export function filterTodos(todos, tab) { + return todos.filter((todo) => { + if (tab === "all") { + return true; + } else if (tab === "active") { + return !todo.completed; + } else if (tab === "completed") { + return todo.completed; + } + }); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +However, here is the same code **with the artificial slowdown removed.** Does the lack of `useMemo` feel noticeable or not? + +```js +import { useState } from "react"; +import { createTodos } from "./utils.js"; +import TodoList from "./TodoList.js"; + +const todos = createTodos(); + +export default function App() { + const [tab, setTab] = useState("all"); + const [isDark, setIsDark] = useState(false); + return ( + <> + <button onClick={() => setTab("all")}>All</button> + <button onClick={() => setTab("active")}>Active</button> + <button onClick={() => setTab("completed")}>Completed</button> + <br /> + <label> + <input + type="checkbox" + checked={isDark} + onChange={(e) => setIsDark(e.target.checked)} + /> + Dark mode + </label> + <hr /> + <TodoList + todos={todos} + tab={tab} + theme={isDark ? "dark" : "light"} + /> + </> + ); +} +``` + +```js +import List from "./List.js"; +import { filterTodos } from "./utils.js"; + +export default function TodoList({ todos, theme, tab }) { + const visibleTodos = filterTodos(todos, tab); + return ( + <div className={theme}> + <List items={visibleTodos} /> + </div> + ); +} +``` + +```js +import { memo } from "react"; + +function List({ items }) { + return ( + <ul> + {items.map((item) => ( + <li key={item.id}> + {item.completed ? <s>{item.text}</s> : item.text} + </li> + ))} + </ul> + ); +} + +export default memo(List); +``` + +```js +export function createTodos() { + const todos = []; + for (let i = 0; i < 50; i++) { + todos.push({ + id: i, + text: "Todo " + (i + 1), + completed: Math.random() > 0.5, + }); + } + return todos; +} + +export function filterTodos(todos, tab) { + return todos.filter((todo) => { + if (tab === "all") { + return true; + } else if (tab === "active") { + return !todo.completed; + } else if (tab === "completed") { + return todo.completed; + } + }); +} +``` + +```css +label { + display: block; + margin-top: 10px; +} + +.dark { + background-color: black; + color: white; +} + +.light { + background-color: white; + color: black; +} +``` + +Quite often, code without memoization works fine. If your interactions are fast enough, you don't need memoization. + +Keep in mind that you need to run React in production mode, disable [React Developer Tools](/learn/react-developer-tools), and use devices similar to the ones your app's users have in order to get a realistic sense of what's actually slowing down your app. + +</Recipes> + +--- + +### Memoizing a dependency of another Hook + +Suppose you have a calculation that depends on an object created directly in the component body: + +```js +function Dropdown({ allItems, text }) { + const searchOptions = { matchMode: 'whole-word', text }; + + const visibleItems = useMemo(() => { + return searchItems(allItems, searchOptions); + }, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body + // ... +``` + +Depending on an object like this defeats the point of memoization. When a component re-renders, all of the code directly inside the component body runs again. **The lines of code creating the `searchOptions` object will also run on every re-render.** Since `searchOptions` is a dependency of your `useMemo` call, and it's different every time, React knows the dependencies are different, and recalculate `searchItems` every time. + +To fix this, you could memoize the `searchOptions` object _itself_ before passing it as a dependency: + +```js +function Dropdown({ allItems, text }) { + const searchOptions = useMemo(() => { + return { matchMode: 'whole-word', text }; + }, [text]); // ✅ Only changes when text changes + + const visibleItems = useMemo(() => { + return searchItems(allItems, searchOptions); + }, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes + // ... +``` + +In the example above, if the `text` did not change, the `searchOptions` object also won't change. However, an even better fix is to move the `searchOptions` object declaration _inside_ of the `useMemo` calculation function: + +```js +function Dropdown({ allItems, text }) { + const visibleItems = useMemo(() => { + const searchOptions = { matchMode: 'whole-word', text }; + return searchItems(allItems, searchOptions); + }, [allItems, text]); // ✅ Only changes when allItems or text changes + // ... +``` + +Now your calculation depends on `text` directly (which is a string and can't "accidentally" become different). + +--- + +### Memoizing a function + +Suppose the `Form` component is wrapped in [`memo`.](/reference/react/memo) You want to pass a function to it as a prop: + +```js +export default function ProductPage({ productId, referrer }) { + function handleSubmit(orderDetails) { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + } + + return <Form onSubmit={handleSubmit} />; +} +``` + +Just as `{}` creates a different object, function declarations like `function() {}` and expressions like `() => {}` produce a _different_ function on every re-render. By itself, creating a new function is not a problem. This is not something to avoid! However, if the `Form` component is memoized, presumably you want to skip re-rendering it when no props have changed. A prop that is _always_ different would defeat the point of memoization. + +To memoize a function with `useMemo`, your calculation function would have to return another function: + +```js +export default function Page({ productId, referrer }) { + const handleSubmit = useMemo(() => { + return (orderDetails) => { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + }; + }, [productId, referrer]); + + return <Form onSubmit={handleSubmit} />; +} +``` + +This looks clunky! **Memoizing functions is common enough that React has a built-in Hook specifically for that. Wrap your functions into [`useCallback`](/reference/react/useCallback) instead of `useMemo`** to avoid having to write an extra nested function: + +```js +export default function Page({ productId, referrer }) { + const handleSubmit = useCallback( + (orderDetails) => { + post("/product/" + productId + "/buy", { + referrer, + orderDetails, + }); + }, + [productId, referrer] + ); + + return <Form onSubmit={handleSubmit} />; +} +``` + +The two examples above are completely equivalent. The only benefit to `useCallback` is that it lets you avoid writing an extra nested function inside. It doesn't do anything else. [Read more about `useCallback`.](/reference/react/useCallback) + +--- + +## Troubleshooting + +### My calculation runs twice on every re-render + +In [Strict Mode](/reference/react/StrictMode), React will call some of your functions twice instead of once: + +```js +function TodoList({ todos, tab }) { + // This component function will run twice for every render. + + const visibleTodos = useMemo(() => { + // This calculation will run twice if any of the dependencies change. + return filterTodos(todos, tab); + }, [todos, tab]); + + // ... +``` + +This is expected and shouldn't break your code. + +This **development-only** behavior helps you [keep components pure.](/learn/keeping-components-pure) React uses the result of one of the calls, and ignores the result of the other call. As long as your component and calculation functions are pure, this shouldn't affect your logic. However, if they are accidentally impure, this helps you notice and fix the mistake. + +For example, this impure calculation function mutates an array you received as a prop: + +```js +const visibleTodos = useMemo(() => { + // 🚩 Mistake: mutating a prop + todos.push({ id: "last", text: "Go for a walk!" }); + const filtered = filterTodos(todos, tab); + return filtered; +}, [todos, tab]); +``` + +React calls your function twice, so you'd notice the todo is added twice. Your calculation shouldn't change any existing objects, but it's okay to change any _new_ objects you created during the calculation. For example, if the `filterTodos` function always returns a _different_ array, you can mutate _that_ array instead: + +```js +const visibleTodos = useMemo(() => { + const filtered = filterTodos(todos, tab); + // ✅ Correct: mutating an object you created during the calculation + filtered.push({ id: "last", text: "Go for a walk!" }); + return filtered; +}, [todos, tab]); +``` + +Read [keeping components pure](/learn/keeping-components-pure) to learn more about purity. + +Also, check out the guides on [updating objects](/learn/updating-objects-in-state) and [updating arrays](/learn/updating-arrays-in-state) without mutation. + +--- + +### My `useMemo` call is supposed to return an object, but returns undefined + +This code doesn't work: + +```js + // 🔴 You can't return an object from an arrow function with () => { + const searchOptions = useMemo(() => { + matchMode: 'whole-word', + text: text + }, [text]); +``` + +In JavaScript, `() => {` starts the arrow function body, so the `{` brace is not a part of your object. This is why it doesn't return an object, and leads to mistakes. You could fix it by adding parentheses like `({` and `})`: + +```js +// This works, but is easy for someone to break again +const searchOptions = useMemo( + () => ({ + matchMode: "whole-word", + text: text, + }), + [text] +); +``` + +However, this is still confusing and too easy for someone to break by removing the parentheses. + +To avoid this mistake, write a `return` statement explicitly: + +```js +// ✅ This works and is explicit +const searchOptions = useMemo(() => { + return { + matchMode: "whole-word", + text: text, + }; +}, [text]); +``` + +--- + +### Every time my component renders, the calculation in `useMemo` re-runs + +Make sure you've specified the dependency array as a second argument! + +If you forget the dependency array, `useMemo` will re-run the calculation every time: + +```js +function TodoList({ todos, tab }) { + // 🔴 Recalculates every time: no dependency array + const visibleTodos = useMemo(() => filterTodos(todos, tab)); + // ... +``` + +This is the corrected version passing the dependency array as a second argument: + +```js +function TodoList({ todos, tab }) { + // ✅ Does not recalculate unnecessarily + const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); + // ... +``` + +If this doesn't help, then the problem is that at least one of your dependencies is different from the previous render. You can debug this problem by manually logging your dependencies to the console: + +```js +const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]); +console.log([todos, tab]); +``` + +You can then right-click on the arrays from different re-renders in the console and select "Store as a global variable" for both of them. Assuming the first one got saved as `temp1` and the second one got saved as `temp2`, you can then use the browser console to check whether each dependency in both arrays is the same: + +```js +Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays? +Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays? +Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ... +``` + +When you find which dependency breaks memoization, either find a way to remove it, or [memoize it as well.](#memoizing-a-dependency-of-another-hook) + +--- + +### I need to call `useMemo` for each list item in a loop, but it's not allowed + +Suppose the `Chart` component is wrapped in [`memo`](/reference/react/memo). You want to skip re-rendering every `Chart` in the list when the `ReportList` component re-renders. However, you can't call `useMemo` in a loop: + +```js +function ReportList({ items }) { + return ( + <article> + {items.map((item) => { + // 🔴 You can't call useMemo in a loop like this: + const data = useMemo(() => calculateReport(item), [item]); + return ( + <figure key={item.id}> + <Chart data={data} /> + </figure> + ); + })} + </article> + ); +} +``` + +Instead, extract a component for each item and memoize data for individual items: + +```js +function ReportList({ items }) { + return ( + <article> + {items.map((item) => ( + <Report key={item.id} item={item} /> + ))} + </article> + ); +} + +function Report({ item }) { + // ✅ Call useMemo at the top level: + const data = useMemo(() => calculateReport(item), [item]); + return ( + <figure> + <Chart data={data} /> + </figure> + ); +} +``` + +Alternatively, you could remove `useMemo` and instead wrap `Report` itself in [`memo`.](/reference/react/memo) If the `item` prop does not change, `Report` will skip re-rendering, so `Chart` will skip re-rendering too: + +```js +function ReportList({ items }) { + // ... +} + +const Report = memo(function Report({ item }) { + const data = calculateReport(item); + return ( + <figure> + <Chart data={data} /> + </figure> + ); +}); +``` diff --git a/docs/src/reference/use-reducer.md b/docs/src/reference/use-reducer.md new file mode 100644 index 000000000..00336376f --- /dev/null +++ b/docs/src/reference/use-reducer.md @@ -0,0 +1,1089 @@ +## Overview + +<p class="intro" markdown> + +`useReducer` is a React Hook that lets you add a [reducer](/learn/extracting-state-logic-into-a-reducer) to your component. + +```js +const [state, dispatch] = useReducer(reducer, initialArg, init?) +``` + +</p> + +--- + +## Reference + +### `useReducer(reducer, initialArg, init?)` + +Call `useReducer` at the top level of your component to manage its state with a [reducer.](/learn/extracting-state-logic-into-a-reducer) + +```js +import { useReducer } from 'react'; + +function reducer(state, action) { + // ... +} + +function MyComponent() { + const [state, dispatch] = useReducer(reducer, { age: 42 }); + // ... +``` + +[See more examples below.](#usage) + +#### Parameters + +- `reducer`: The reducer function that specifies how the state gets updated. It must be pure, should take the state and action as arguments, and should return the next state. State and action can be of any types. +- `initialArg`: The value from which the initial state is calculated. It can be a value of any type. How the initial state is calculated from it depends on the next `init` argument. +- **optional** `init`: The initializer function that should return the initial state. If it's not specified, the initial state is set to `initialArg`. Otherwise, the initial state is set to the result of calling `init(initialArg)`. + +#### Returns + +`useReducer` returns an array with exactly two values: + +1. The current state. During the first render, it's set to `init(initialArg)` or `initialArg` (if there's no `init`). +2. The [`dispatch` function](#dispatch) that lets you update the state to a different value and trigger a re-render. + +#### Caveats + +- `useReducer` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. +- In Strict Mode, React will **call your reducer and initializer twice** in order to [help you find accidental impurities.](#my-reducer-or-initializer-function-runs-twice) This is development-only behavior and does not affect production. If your reducer and initializer are pure (as they should be), this should not affect your logic. The result from one of the calls is ignored. + +--- + +### `dispatch` function + +The `dispatch` function returned by `useReducer` lets you update the state to a different value and trigger a re-render. You need to pass the action as the only argument to the `dispatch` function: + +```js +const [state, dispatch] = useReducer(reducer, { age: 42 }); + +function handleClick() { + dispatch({ type: 'incremented_age' }); + // ... +``` + +React will set the next state to the result of calling the `reducer` function you've provided with the current `state` and the action you've passed to `dispatch`. + +#### Parameters + +- `action`: The action performed by the user. It can be a value of any type. By convention, an action is usually an object with a `type` property identifying it and, optionally, other properties with additional information. + +#### Returns + +`dispatch` functions do not have a return value. + +#### Caveats + +- The `dispatch` function **only updates the state variable for the _next_ render**. If you read the state variable after calling the `dispatch` function, [you will still get the old value](#ive-dispatched-an-action-but-logging-gives-me-the-old-state-value) that was on the screen before your call. + +- If the new value you provide is identical to the current `state`, as determined by an [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison, React will **skip re-rendering the component and its children.** This is an optimization. React may still need to call your component before ignoring the result, but it shouldn't affect your code. + +- React [batches state updates.](/learn/queueing-a-series-of-state-updates) It updates the screen **after all the event handlers have run** and have called their `set` functions. This prevents multiple re-renders during a single event. In the rare case that you need to force React to update the screen earlier, for example to access the DOM, you can use [`flushSync`.](/reference/react-dom/flushSync) + +--- + +## Usage + +### Adding a reducer to a component + +Call `useReducer` at the top level of your component to manage state with a [reducer.](/learn/extracting-state-logic-into-a-reducer) + +```js +import { useReducer } from 'react'; + +function reducer(state, action) { + // ... +} + +function MyComponent() { + const [state, dispatch] = useReducer(reducer, { age: 42 }); + // ... +``` + +`useReducer` returns an array with exactly two items: + +1. The <CodeStep step={1}>current state</CodeStep> of this state variable, initially set to the <CodeStep step={3}>initial state</CodeStep> you provided. +2. The <CodeStep step={2}>`dispatch` function</CodeStep> that lets you change it in response to interaction. + +To update what's on the screen, call <CodeStep step={2}>`dispatch`</CodeStep> with an object representing what the user did, called an _action_: + +```js +function handleClick() { + dispatch({ type: "incremented_age" }); +} +``` + +React will pass the current state and the action to your <CodeStep step={4}>reducer function</CodeStep>. Your reducer will calculate and return the next state. React will store that next state, render your component with it, and update the UI. + +```js +import { useReducer } from "react"; + +function reducer(state, action) { + if (action.type === "incremented_age") { + return { + age: state.age + 1, + }; + } + throw Error("Unknown action."); +} + +export default function Counter() { + const [state, dispatch] = useReducer(reducer, { age: 42 }); + + return ( + <> + <button + onClick={() => { + dispatch({ type: "incremented_age" }); + }} + > + Increment age + </button> + <p>Hello! You are {state.age}.</p> + </> + ); +} +``` + +```css +button { + display: block; + margin-top: 10px; +} +``` + +`useReducer` is very similar to [`useState`](/reference/react/useState), but it lets you move the state update logic from event handlers into a single function outside of your component. Read more about [choosing between `useState` and `useReducer`.](/learn/extracting-state-logic-into-a-reducer#comparing-usestate-and-usereducer) + +--- + +### Writing the reducer function + +A reducer function is declared like this: + +```js +function reducer(state, action) { + // ... +} +``` + +Then you need to fill in the code that will calculate and return the next state. By convention, it is common to write it as a [`switch` statement.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch) For each `case` in the `switch`, calculate and return some next state. + +```js +function reducer(state, action) { + switch (action.type) { + case "incremented_age": { + return { + name: state.name, + age: state.age + 1, + }; + } + case "changed_name": { + return { + name: action.nextName, + age: state.age, + }; + } + } + throw Error("Unknown action: " + action.type); +} +``` + +Actions can have any shape. By convention, it's common to pass objects with a `type` property identifying the action. It should include the minimal necessary information that the reducer needs to compute the next state. + +```js +function Form() { + const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 }); + + function handleButtonClick() { + dispatch({ type: 'incremented_age' }); + } + + function handleInputChange(e) { + dispatch({ + type: 'changed_name', + nextName: e.target.value + }); + } + // ... +``` + +The action type names are local to your component. [Each action describes a single interaction, even if that leads to multiple changes in data.](/learn/extracting-state-logic-into-a-reducer#writing-reducers-well) The shape of the state is arbitrary, but usually it'll be an object or an array. + +Read [extracting state logic into a reducer](/learn/extracting-state-logic-into-a-reducer) to learn more. + +<Pitfall> + +State is read-only. Don't modify any objects or arrays in state: + +```js +function reducer(state, action) { + switch (action.type) { + case 'incremented_age': { + // 🚩 Don't mutate an object in state like this: + state.age = state.age + 1; + return state; + } +``` + +Instead, always return new objects from your reducer: + +```js +function reducer(state, action) { + switch (action.type) { + case 'incremented_age': { + // ✅ Instead, return a new object + return { + ...state, + age: state.age + 1 + }; + } +``` + +Read [updating objects in state](/learn/updating-objects-in-state) and [updating arrays in state](/learn/updating-arrays-in-state) to learn more. + +</Pitfall> + +<Recipes titleText="Basic useReducer examples" titleId="examples-basic"> + +#### Form (object) + +In this example, the reducer manages a state object with two fields: `name` and `age`. + +```js +import { useReducer } from "react"; + +function reducer(state, action) { + switch (action.type) { + case "incremented_age": { + return { + name: state.name, + age: state.age + 1, + }; + } + case "changed_name": { + return { + name: action.nextName, + age: state.age, + }; + } + } + throw Error("Unknown action: " + action.type); +} + +const initialState = { name: "Taylor", age: 42 }; + +export default function Form() { + const [state, dispatch] = useReducer(reducer, initialState); + + function handleButtonClick() { + dispatch({ type: "incremented_age" }); + } + + function handleInputChange(e) { + dispatch({ + type: "changed_name", + nextName: e.target.value, + }); + } + + return ( + <> + <input value={state.name} onChange={handleInputChange} /> + <button onClick={handleButtonClick}>Increment age</button> + <p> + Hello, {state.name}. You are {state.age}. + </p> + </> + ); +} +``` + +```css +button { + display: block; + margin-top: 10px; +} +``` + +#### Todo list (array) + +In this example, the reducer manages an array of tasks. The array needs to be updated [without mutation.](/learn/updating-arrays-in-state) + +```js +import { useReducer } from "react"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +function tasksReducer(tasks, action) { + switch (action.type) { + case "added": { + return [ + ...tasks, + { + id: action.id, + text: action.text, + done: false, + }, + ]; + } + case "changed": { + return tasks.map((t) => { + if (t.id === action.task.id) { + return action.task; + } else { + return t; + } + }); + } + case "deleted": { + return tasks.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +export default function TaskApp() { + const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Prague itinerary</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Visit Kafka Museum", done: true }, + { id: 1, text: "Watch a puppet show", done: false }, + { id: 2, text: "Lennon Wall pic", done: false }, +]; +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + onClick={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button onClick={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button onClick={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button onClick={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +#### Writing concise update logic with Immer + +If updating arrays and objects without mutation feels tedious, you can use a library like [Immer](https://github.com/immerjs/use-immer#useimmerreducer) to reduce repetitive code. Immer lets you write concise code as if you were mutating objects, but under the hood it performs immutable updates: + +```js +import { useImmerReducer } from "use-immer"; +import AddTask from "./AddTask.js"; +import TaskList from "./TaskList.js"; + +function tasksReducer(draft, action) { + switch (action.type) { + case "added": { + draft.push({ + id: action.id, + text: action.text, + done: false, + }); + break; + } + case "changed": { + const index = draft.findIndex((t) => t.id === action.task.id); + draft[index] = action.task; + break; + } + case "deleted": { + return draft.filter((t) => t.id !== action.id); + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +export default function TaskApp() { + const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks); + + function handleAddTask(text) { + dispatch({ + type: "added", + id: nextId++, + text: text, + }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + return ( + <> + <h1>Prague itinerary</h1> + <AddTask onAddTask={handleAddTask} /> + <TaskList + tasks={tasks} + onChangeTask={handleChangeTask} + onDeleteTask={handleDeleteTask} + /> + </> + ); +} + +let nextId = 3; +const initialTasks = [ + { id: 0, text: "Visit Kafka Museum", done: true }, + { id: 1, text: "Watch a puppet show", done: false }, + { id: 2, text: "Lennon Wall pic", done: false }, +]; +``` + +```js +import { useState } from "react"; + +export default function AddTask({ onAddTask }) { + const [text, setText] = useState(""); + return ( + <> + <input + placeholder="Add task" + value={text} + onChange={(e) => setText(e.target.value)} + /> + <button + onClick={() => { + setText(""); + onAddTask(text); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ tasks, onChangeTask, onDeleteTask }) { + return ( + <ul> + {tasks.map((task) => ( + <li key={task.id}> + <Task + task={task} + onChange={onChangeTask} + onDelete={onDeleteTask} + /> + </li> + ))} + </ul> + ); +} + +function Task({ task, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let taskContent; + if (isEditing) { + taskContent = ( + <> + <input + value={task.text} + onChange={(e) => { + onChange({ + ...task, + text: e.target.value, + }); + }} + /> + <button onClick={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + taskContent = ( + <> + {task.text} + <button onClick={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={task.done} + onChange={(e) => { + onChange({ + ...task, + done: e.target.checked, + }); + }} + /> + {taskContent} + <button onClick={() => onDelete(task.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +```json package.json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +</Recipes> + +--- + +### Avoiding recreating the initial state + +React saves the initial state once and ignores it on the next renders. + +```js +function createInitialState(username) { + // ... +} + +function TodoList({ username }) { + const [state, dispatch] = useReducer(reducer, createInitialState(username)); + // ... +``` + +Although the result of `createInitialState(username)` is only used for the initial render, you're still calling this function on every render. This can be wasteful if it's creating large arrays or performing expensive calculations. + +To solve this, you may **pass it as an _initializer_ function** to `useReducer` as the third argument instead: + +```js +function createInitialState(username) { + // ... +} + +function TodoList({ username }) { + const [state, dispatch] = useReducer(reducer, username, createInitialState); + // ... +``` + +Notice that you’re passing `createInitialState`, which is the _function itself_, and not `createInitialState()`, which is the result of calling it. This way, the initial state does not get re-created after initialization. + +In the above example, `createInitialState` takes a `username` argument. If your initializer doesn't need any information to compute the initial state, you may pass `null` as the second argument to `useReducer`. + +<Recipes titleText="The difference between passing an initializer and passing the initial state directly" titleId="examples-initializer"> + +#### Passing the initializer function + +This example passes the initializer function, so the `createInitialState` function only runs during initialization. It does not run when component re-renders, such as when you type into the input. + +```js +import TodoList from "./TodoList.js"; + +export default function App() { + return <TodoList username="Taylor" />; +} +``` + +```js +import { useReducer } from "react"; + +function createInitialState(username) { + const initialTodos = []; + for (let i = 0; i < 50; i++) { + initialTodos.push({ + id: i, + text: username + "'s task #" + (i + 1), + }); + } + return { + draft: "", + todos: initialTodos, + }; +} + +function reducer(state, action) { + switch (action.type) { + case "changed_draft": { + return { + draft: action.nextDraft, + todos: state.todos, + }; + } + case "added_todo": { + return { + draft: "", + todos: [ + { + id: state.todos.length, + text: state.draft, + }, + ...state.todos, + ], + }; + } + } + throw Error("Unknown action: " + action.type); +} + +export default function TodoList({ username }) { + const [state, dispatch] = useReducer(reducer, username, createInitialState); + return ( + <> + <input + value={state.draft} + onChange={(e) => { + dispatch({ + type: "changed_draft", + nextDraft: e.target.value, + }); + }} + /> + <button + onClick={() => { + dispatch({ type: "added_todo" }); + }} + > + Add + </button> + <ul> + {state.todos.map((item) => ( + <li key={item.id}>{item.text}</li> + ))} + </ul> + </> + ); +} +``` + +#### Passing the initial state directly + +This example **does not** pass the initializer function, so the `createInitialState` function runs on every render, such as when you type into the input. There is no observable difference in behavior, but this code is less efficient. + +```js +import TodoList from "./TodoList.js"; + +export default function App() { + return <TodoList username="Taylor" />; +} +``` + +```js +import { useReducer } from "react"; + +function createInitialState(username) { + const initialTodos = []; + for (let i = 0; i < 50; i++) { + initialTodos.push({ + id: i, + text: username + "'s task #" + (i + 1), + }); + } + return { + draft: "", + todos: initialTodos, + }; +} + +function reducer(state, action) { + switch (action.type) { + case "changed_draft": { + return { + draft: action.nextDraft, + todos: state.todos, + }; + } + case "added_todo": { + return { + draft: "", + todos: [ + { + id: state.todos.length, + text: state.draft, + }, + ...state.todos, + ], + }; + } + } + throw Error("Unknown action: " + action.type); +} + +export default function TodoList({ username }) { + const [state, dispatch] = useReducer(reducer, createInitialState(username)); + return ( + <> + <input + value={state.draft} + onChange={(e) => { + dispatch({ + type: "changed_draft", + nextDraft: e.target.value, + }); + }} + /> + <button + onClick={() => { + dispatch({ type: "added_todo" }); + }} + > + Add + </button> + <ul> + {state.todos.map((item) => ( + <li key={item.id}>{item.text}</li> + ))} + </ul> + </> + ); +} +``` + +</Recipes> + +--- + +## Troubleshooting + +### I've dispatched an action, but logging gives me the old state value + +Calling the `dispatch` function **does not change state in the running code**: + +```js +function handleClick() { + console.log(state.age); // 42 + + dispatch({ type: "incremented_age" }); // Request a re-render with 43 + console.log(state.age); // Still 42! + + setTimeout(() => { + console.log(state.age); // Also 42! + }, 5000); +} +``` + +This is because [states behaves like a snapshot.](/learn/state-as-a-snapshot) Updating state requests another render with the new state value, but does not affect the `state` JavaScript variable in your already-running event handler. + +If you need to guess the next state value, you can calculate it manually by calling the reducer yourself: + +```js +const action = { type: "incremented_age" }; +dispatch(action); + +const nextState = reducer(state, action); +console.log(state); // { age: 42 } +console.log(nextState); // { age: 43 } +``` + +--- + +### I've dispatched an action, but the screen doesn't update + +React will **ignore your update if the next state is equal to the previous state,** as determined by an [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. This usually happens when you change an object or an array in state directly: + +```js +function reducer(state, action) { + switch (action.type) { + case "incremented_age": { + // 🚩 Wrong: mutating existing object + state.age++; + return state; + } + case "changed_name": { + // 🚩 Wrong: mutating existing object + state.name = action.nextName; + return state; + } + // ... + } +} +``` + +You mutated an existing `state` object and returned it, so React ignored the update. To fix this, you need to ensure that you're always [updating objects in state](/learn/updating-objects-in-state) and [updating arrays in state](/learn/updating-arrays-in-state) instead of mutating them: + +```js +function reducer(state, action) { + switch (action.type) { + case "incremented_age": { + // ✅ Correct: creating a new object + return { + ...state, + age: state.age + 1, + }; + } + case "changed_name": { + // ✅ Correct: creating a new object + return { + ...state, + name: action.nextName, + }; + } + // ... + } +} +``` + +--- + +### A part of my reducer state becomes undefined after dispatching + +Make sure that every `case` branch **copies all of the existing fields** when returning the new state: + +```js +function reducer(state, action) { + switch (action.type) { + case 'incremented_age': { + return { + ...state, // Don't forget this! + age: state.age + 1 + }; + } + // ... +``` + +Without `...state` above, the returned next state would only contain the `age` field and nothing else. + +--- + +### My entire reducer state becomes undefined after dispatching + +If your state unexpectedly becomes `undefined`, you're likely forgetting to `return` state in one of the cases, or your action type doesn't match any of the `case` statements. To find why, throw an error outside the `switch`: + +```js +function reducer(state, action) { + switch (action.type) { + case "incremented_age": { + // ... + } + case "edited_name": { + // ... + } + } + throw Error("Unknown action: " + action.type); +} +``` + +You can also use a static type checker like TypeScript to catch such mistakes. + +--- + +### I'm getting an error: "Too many re-renders" + +You might get an error that says: `Too many re-renders. React limits the number of renders to prevent an infinite loop.` Typically, this means that you're unconditionally dispatching an action _during render_, so your component enters a loop: render, dispatch (which causes a render), render, dispatch (which causes a render), and so on. Very often, this is caused by a mistake in specifying an event handler: + +```js +// 🚩 Wrong: calls the handler during render +return <button onClick={handleClick()}>Click me</button>; + +// ✅ Correct: passes down the event handler +return <button onClick={handleClick}>Click me</button>; + +// ✅ Correct: passes down an inline function +return <button onClick={(e) => handleClick(e)}>Click me</button>; +``` + +If you can't find the cause of this error, click on the arrow next to the error in the console and look through the JavaScript stack to find the specific `dispatch` function call responsible for the error. + +--- + +### My reducer or initializer function runs twice + +In [Strict Mode](/reference/react/StrictMode), React will call your reducer and initializer functions twice. This shouldn't break your code. + +This **development-only** behavior helps you [keep components pure.](/learn/keeping-components-pure) React uses the result of one of the calls, and ignores the result of the other call. As long as your component, initializer, and reducer functions are pure, this shouldn't affect your logic. However, if they are accidentally impure, this helps you notice the mistakes. + +For example, this impure reducer function mutates an array in state: + +```js +function reducer(state, action) { + switch (action.type) { + case "added_todo": { + // 🚩 Mistake: mutating state + state.todos.push({ id: nextId++, text: action.text }); + return state; + } + // ... + } +} +``` + +Because React calls your reducer function twice, you'll see the todo was added twice, so you'll know that there is a mistake. In this example, you can fix the mistake by [replacing the array instead of mutating it](/learn/updating-arrays-in-state#adding-to-an-array): + +```js +function reducer(state, action) { + switch (action.type) { + case "added_todo": { + // ✅ Correct: replacing with new state + return { + ...state, + todos: [...state.todos, { id: nextId++, text: action.text }], + }; + } + // ... + } +} +``` + +Now that this reducer function is pure, calling it an extra time doesn't make a difference in behavior. This is why React calling it twice helps you find mistakes. **Only component, initializer, and reducer functions need to be pure.** Event handlers don't need to be pure, so React will never call your event handlers twice. + +Read [keeping components pure](/learn/keeping-components-pure) to learn more. diff --git a/docs/src/reference/use-ref.md b/docs/src/reference/use-ref.md new file mode 100644 index 000000000..4cac76685 --- /dev/null +++ b/docs/src/reference/use-ref.md @@ -0,0 +1,530 @@ +## Overview + +<p class="intro" markdown> + +`useRef` is a React Hook that lets you reference a value that's not needed for rendering. + +```js +const ref = useRef(initialValue); +``` + +</p> + +--- + +## Reference + +### `useRef(initialValue)` + +Call `useRef` at the top level of your component to declare a [ref.](/learn/referencing-values-with-refs) + +```js +import { useRef } from 'react'; + +function MyComponent() { + const intervalRef = useRef(0); + const inputRef = useRef(null); + // ... +``` + +[See more examples below.](#usage) + +#### Parameters + +- `initialValue`: The value you want the ref object's `current` property to be initially. It can be a value of any type. This argument is ignored after the initial render. + +#### Returns + +`useRef` returns an object with a single property: + +- `current`: Initially, it's set to the `initialValue` you have passed. You can later set it to something else. If you pass the ref object to React as a `ref` attribute to a JSX node, React will set its `current` property. + +On the next renders, `useRef` will return the same object. + +#### Caveats + +- You can mutate the `ref.current` property. Unlike state, it is mutable. However, if it holds an object that is used for rendering (for example, a piece of your state), then you shouldn't mutate that object. +- When you change the `ref.current` property, React does not re-render your component. React is not aware of when you change it because a ref is a plain JavaScript object. +- Do not write _or read_ `ref.current` during rendering, except for [initialization.](#avoiding-recreating-the-ref-contents) This makes your component's behavior unpredictable. +- In Strict Mode, React will **call your component function twice** in order to [help you find accidental impurities.](#my-initializer-or-updater-function-runs-twice) This is development-only behavior and does not affect production. Each ref object will be created twice, but one of the versions will be discarded. If your component function is pure (as it should be), this should not affect the behavior. + +--- + +## Usage + +### Referencing a value with a ref + +Call `useRef` at the top level of your component to declare one or more [refs.](/learn/referencing-values-with-refs) + +```js +import { useRef } from 'react'; + +function Stopwatch() { + const intervalRef = useRef(0); + // ... +``` + +`useRef` returns a <CodeStep step={1}>ref object</CodeStep> with a single <CodeStep step={2}>`current` property</CodeStep> initially set to the <CodeStep step={3}>initial value</CodeStep> you provided. + +On the next renders, `useRef` will return the same object. You can change its `current` property to store information and read it later. This might remind you of [state](/reference/react/useState), but there is an important difference. + +**Changing a ref does not trigger a re-render.** This means refs are perfect for storing information that doesn't affect the visual output of your component. For example, if you need to store an [interval ID](https://developer.mozilla.org/en-US/docs/Web/API/setInterval) and retrieve it later, you can put it in a ref. To update the value inside the ref, you need to manually change its <CodeStep step={2}>`current` property</CodeStep>: + +```js +function handleStartClick() { + const intervalId = setInterval(() => { + // ... + }, 1000); + intervalRef.current = intervalId; +} +``` + +Later, you can read that interval ID from the ref so that you can call [clear that interval](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval): + +```js +function handleStopClick() { + const intervalId = intervalRef.current; + clearInterval(intervalId); +} +``` + +By using a ref, you ensure that: + +- You can **store information** between re-renders (unlike regular variables, which reset on every render). +- Changing it **does not trigger a re-render** (unlike state variables, which trigger a re-render). +- The **information is local** to each copy of your component (unlike the variables outside, which are shared). + +Changing a ref does not trigger a re-render, so refs are not appropriate for storing information you want to display on the screen. Use state for that instead. Read more about [choosing between `useRef` and `useState`.](/learn/referencing-values-with-refs#differences-between-refs-and-state) + +<Recipes titleText="Examples of referencing a value with useRef" titleId="examples-value"> + +#### Click counter + +This component uses a ref to keep track of how many times the button was clicked. Note that it's okay to use a ref instead of state here because the click count is only read and written in an event handler. + +```js +import { useRef } from "react"; + +export default function Counter() { + let ref = useRef(0); + + function handleClick() { + ref.current = ref.current + 1; + alert("You clicked " + ref.current + " times!"); + } + + return <button onClick={handleClick}>Click me!</button>; +} +``` + +If you show `{ref.current}` in the JSX, the number won't update on click. This is because setting `ref.current` does not trigger a re-render. Information that's used for rendering should be state instead. + +#### A stopwatch + +This example uses a combination of state and refs. Both `startTime` and `now` are state variables because they are used for rendering. But we also need to hold an [interval ID](https://developer.mozilla.org/en-US/docs/Web/API/setInterval) so that we can stop the interval on button press. Since the interval ID is not used for rendering, it's appropriate to keep it in a ref, and manually update it. + +```js +import { useState, useRef } from "react"; + +export default function Stopwatch() { + const [startTime, setStartTime] = useState(null); + const [now, setNow] = useState(null); + const intervalRef = useRef(null); + + function handleStart() { + setStartTime(Date.now()); + setNow(Date.now()); + + clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + setNow(Date.now()); + }, 10); + } + + function handleStop() { + clearInterval(intervalRef.current); + } + + let secondsPassed = 0; + if (startTime != null && now != null) { + secondsPassed = (now - startTime) / 1000; + } + + return ( + <> + <h1>Time passed: {secondsPassed.toFixed(3)}</h1> + <button onClick={handleStart}>Start</button> + <button onClick={handleStop}>Stop</button> + </> + ); +} +``` + +</Recipes> + +<Pitfall> + +**Do not write _or read_ `ref.current` during rendering.** + +React expects that the body of your component [behaves like a pure function](/learn/keeping-components-pure): + +- If the inputs ([props](/learn/passing-props-to-a-component), [state](/learn/state-a-components-memory), and [context](/learn/passing-data-deeply-with-context)) are the same, it should return exactly the same JSX. +- Calling it in a different order or with different arguments should not affect the results of other calls. + +Reading or writing a ref **during rendering** breaks these expectations. + +```js +function MyComponent() { + // ... + // 🚩 Don't write a ref during rendering + myRef.current = 123; + // ... + // 🚩 Don't read a ref during rendering + return <h1>{myOtherRef.current}</h1>; +} +``` + +You can read or write refs **from event handlers or effects instead**. + +```js +function MyComponent() { + // ... + useEffect(() => { + // ✅ You can read or write refs in effects + myRef.current = 123; + }); + // ... + function handleClick() { + // ✅ You can read or write refs in event handlers + doSomething(myOtherRef.current); + } + // ... +} +``` + +If you _have to_ read [or write](/reference/react/useState#storing-information-from-previous-renders) something during rendering, [use state](/reference/react/useState) instead. + +When you break these rules, your component might still work, but most of the newer features we're adding to React will rely on these expectations. Read more about [keeping your components pure.](/learn/keeping-components-pure#where-you-can-cause-side-effects) + +</Pitfall> + +--- + +### Manipulating the DOM with a ref + +It's particularly common to use a ref to manipulate the [DOM.](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API) React has built-in support for this. + +First, declare a <CodeStep step={1}>ref object</CodeStep> with an <CodeStep step={3}>initial value</CodeStep> of `null`: + +```js +import { useRef } from 'react'; + +function MyComponent() { + const inputRef = useRef(null); + // ... +``` + +Then pass your ref object as the `ref` attribute to the JSX of the DOM node you want to manipulate: + +```js +// ... +return <input ref={inputRef} />; +``` + +After React creates the DOM node and puts it on the screen, React will set the <CodeStep step={2}>`current` property</CodeStep> of your ref object to that DOM node. Now you can access the `<input>`'s DOM node and call methods like [`focus()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus): + +```js +function handleClick() { + inputRef.current.focus(); +} +``` + +React will set the `current` property back to `null` when the node is removed from the screen. + +Read more about [manipulating the DOM with refs.](/learn/manipulating-the-dom-with-refs) + +<Recipes titleText="Examples of manipulating the DOM with useRef" titleId="examples-dom"> + +#### Focusing a text input + +In this example, clicking the button will focus the input: + +```js +import { useRef } from "react"; + +export default function Form() { + const inputRef = useRef(null); + + function handleClick() { + inputRef.current.focus(); + } + + return ( + <> + <input ref={inputRef} /> + <button onClick={handleClick}>Focus the input</button> + </> + ); +} +``` + +#### Scrolling an image into view + +In this example, clicking the button will scroll an image into view. It uses a ref to the list DOM node, and then calls DOM [`querySelectorAll`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll) API to find the image we want to scroll to. + +```js +import { useRef } from "react"; + +export default function CatFriends() { + const listRef = useRef(null); + + function scrollToIndex(index) { + const listNode = listRef.current; + // This line assumes a particular DOM structure: + const imgNode = listNode.querySelectorAll("li > img")[index]; + imgNode.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + + return ( + <> + <nav> + <button onClick={() => scrollToIndex(0)}>Tom</button> + <button onClick={() => scrollToIndex(1)}>Maru</button> + <button onClick={() => scrollToIndex(2)}>Jellylorum</button> + </nav> + <div> + <ul ref={listRef}> + <li> + <img + src="https://placekitten.com/g/200/200" + alt="Tom" + /> + </li> + <li> + <img + src="https://placekitten.com/g/300/200" + alt="Maru" + /> + </li> + <li> + <img + src="https://placekitten.com/g/250/200" + alt="Jellylorum" + /> + </li> + </ul> + </div> + </> + ); +} +``` + +```css +div { + width: 100%; + overflow: hidden; +} + +nav { + text-align: center; +} + +button { + margin: 0.25rem; +} + +ul, +li { + list-style: none; + white-space: nowrap; +} + +li { + display: inline; + padding: 0.5rem; +} +``` + +#### Playing and pausing a video + +This example uses a ref to call [`play()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play) and [`pause()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause) on a `<video>` DOM node. + +```js +import { useState, useRef } from "react"; + +export default function VideoPlayer() { + const [isPlaying, setIsPlaying] = useState(false); + const ref = useRef(null); + + function handleClick() { + const nextIsPlaying = !isPlaying; + setIsPlaying(nextIsPlaying); + + if (nextIsPlaying) { + ref.current.play(); + } else { + ref.current.pause(); + } + } + + return ( + <> + <button onClick={handleClick}> + {isPlaying ? "Pause" : "Play"} + </button> + <video + width="250" + ref={ref} + onPlay={() => setIsPlaying(true)} + onPause={() => setIsPlaying(false)} + > + <source + src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" + type="video/mp4" + /> + </video> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 20px; +} +``` + +#### Exposing a ref to your own component + +Sometimes, you may want to let the parent component manipulate the DOM inside of your component. For example, maybe you're writing a `MyInput` component, but you want the parent to be able to focus the input (which the parent has no access to). You can use a combination of `useRef` to hold the input and [`forwardRef`](/reference/react/forwardRef) to expose it to the parent component. Read a [detailed walkthrough](/learn/manipulating-the-dom-with-refs#accessing-another-components-dom-nodes) here. + +```js +import { forwardRef, useRef } from "react"; + +const MyInput = forwardRef((props, ref) => { + return <input {...props} ref={ref} />; +}); + +export default function Form() { + const inputRef = useRef(null); + + function handleClick() { + inputRef.current.focus(); + } + + return ( + <> + <MyInput ref={inputRef} /> + <button onClick={handleClick}>Focus the input</button> + </> + ); +} +``` + +</Recipes> + +--- + +### Avoiding recreating the ref contents + +React saves the initial ref value once and ignores it on the next renders. + +```js +function Video() { + const playerRef = useRef(new VideoPlayer()); + // ... +``` + +Although the result of `new VideoPlayer()` is only used for the initial render, you're still calling this function on every render. This can be wasteful if it's creating expensive objects. + +To solve it, you may initialize the ref like this instead: + +```js +function Video() { + const playerRef = useRef(null); + if (playerRef.current === null) { + playerRef.current = new VideoPlayer(); + } + // ... +``` + +Normally, writing or reading `ref.current` during render is not allowed. However, it's fine in this case because the result is always the same, and the condition only executes during initialization so it's fully predictable. + +<DeepDive> + +#### How to avoid null checks when initializing useRef later + +If you use a type checker and don't want to always check for `null`, you can try a pattern like this instead: + +```js +function Video() { + const playerRef = useRef(null); + + function getPlayer() { + if (playerRef.current !== null) { + return playerRef.current; + } + const player = new VideoPlayer(); + playerRef.current = player; + return player; + } + + // ... +``` + +Here, the `playerRef` itself is nullable. However, you should be able to convince your type checker that there is no case in which `getPlayer()` returns `null`. Then use `getPlayer()` in your event handlers. + +</DeepDive> + +--- + +## Troubleshooting + +### I can't get a ref to a custom component + +If you try to pass a `ref` to your own component like this: + +```js +const inputRef = useRef(null); + +return <MyInput ref={inputRef} />; +``` + +You might get an error in the console: + +<ConsoleBlock level="error"> + +Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? + +</ConsoleBlock> + +By default, your own components don't expose refs to the DOM nodes inside them. + +To fix this, find the component that you want to get a ref to: + +```js +export default function MyInput({ value, onChange }) { + return <input value={value} onChange={onChange} />; +} +``` + +And then wrap it in [`forwardRef`](/reference/react/forwardRef) like this: + +```js +import { forwardRef } from "react"; + +const MyInput = forwardRef(({ value, onChange }, ref) => { + return <input value={value} onChange={onChange} ref={ref} />; +}); + +export default MyInput; +``` + +Then the parent component can get a ref to it. + +Read more about [accessing another component's DOM nodes.](/learn/manipulating-the-dom-with-refs#accessing-another-components-dom-nodes) diff --git a/docs/src/reference/use-scope.md b/docs/src/reference/use-scope.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/use-state.md b/docs/src/reference/use-state.md new file mode 100644 index 000000000..adf75fb0c --- /dev/null +++ b/docs/src/reference/use-state.md @@ -0,0 +1,1230 @@ +## Overview + +<p class="intro" markdown> + +`useState` is a React Hook that lets you add a [state variable](/learn/state-a-components-memory) to your component. + +```js +const [state, setState] = useState(initialState); +``` + +</p> + +--- + +## Reference + +### `useState(initialState)` + +Call `useState` at the top level of your component to declare a [state variable.](/learn/state-a-components-memory) + +```js +import { useState } from 'react'; + +function MyComponent() { + const [age, setAge] = useState(28); + const [name, setName] = useState('Taylor'); + const [todos, setTodos] = useState(() => createTodos()); + // ... +``` + +The convention is to name state variables like `[something, setSomething]` using [array destructuring.](https://javascript.info/destructuring-assignment) + +[See more examples below.](#usage) + +#### Parameters + +- `initialState`: The value you want the state to be initially. It can be a value of any type, but there is a special behavior for functions. This argument is ignored after the initial render. + - If you pass a function as `initialState`, it will be treated as an _initializer function_. It should be pure, should take no arguments, and should return a value of any type. React will call your initializer function when initializing the component, and store its return value as the initial state. [See an example below.](#avoiding-recreating-the-initial-state) + +#### Returns + +`useState` returns an array with exactly two values: + +1. The current state. During the first render, it will match the `initialState` you have passed. +2. The [`set` function](#setstate) that lets you update the state to a different value and trigger a re-render. + +#### Caveats + +- `useState` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it. +- In Strict Mode, React will **call your initializer function twice** in order to [help you find accidental impurities.](#my-initializer-or-updater-function-runs-twice) This is development-only behavior and does not affect production. If your initializer function is pure (as it should be), this should not affect the behavior. The result from one of the calls will be ignored. + +--- + +### `set` functions, like `setSomething(nextState)` + +The `set` function returned by `useState` lets you update the state to a different value and trigger a re-render. You can pass the next state directly, or a function that calculates it from the previous state: + +```js +const [name, setName] = useState('Edward'); + +function handleClick() { + setName('Taylor'); + setAge(a => a + 1); + // ... +``` + +#### Parameters + +- `nextState`: The value that you want the state to be. It can be a value of any type, but there is a special behavior for functions. + - If you pass a function as `nextState`, it will be treated as an _updater function_. It must be pure, should take the pending state as its only argument, and should return the next state. React will put your updater function in a queue and re-render your component. During the next render, React will calculate the next state by applying all of the queued updaters to the previous state. [See an example below.](#updating-state-based-on-the-previous-state) + +#### Returns + +`set` functions do not have a return value. + +#### Caveats + +- The `set` function **only updates the state variable for the _next_ render**. If you read the state variable after calling the `set` function, [you will still get the old value](#ive-updated-the-state-but-logging-gives-me-the-old-value) that was on the screen before your call. + +- If the new value you provide is identical to the current `state`, as determined by an [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison, React will **skip re-rendering the component and its children.** This is an optimization. Although in some cases React may still need to call your component before skipping the children, it shouldn't affect your code. + +- React [batches state updates.](/learn/queueing-a-series-of-state-updates) It updates the screen **after all the event handlers have run** and have called their `set` functions. This prevents multiple re-renders during a single event. In the rare case that you need to force React to update the screen earlier, for example to access the DOM, you can use [`flushSync`.](/reference/react-dom/flushSync) + +- Calling the `set` function _during rendering_ is only allowed from within the currently rendering component. React will discard its output and immediately attempt to render it again with the new state. This pattern is rarely needed, but you can use it to **store information from the previous renders**. [See an example below.](#storing-information-from-previous-renders) + +- In Strict Mode, React will **call your updater function twice** in order to [help you find accidental impurities.](#my-initializer-or-updater-function-runs-twice) This is development-only behavior and does not affect production. If your updater function is pure (as it should be), this should not affect the behavior. The result from one of the calls will be ignored. + +--- + +## Usage + +### Adding state to a component + +Call `useState` at the top level of your component to declare one or more [state variables.](/learn/state-a-components-memory) + +```js +import { useState } from 'react'; + +function MyComponent() { + const [age, setAge] = useState(42); + const [name, setName] = useState('Taylor'); + // ... +``` + +The convention is to name state variables like `[something, setSomething]` using [array destructuring.](https://javascript.info/destructuring-assignment) + +`useState` returns an array with exactly two items: + +1. The <CodeStep step={1}>current state</CodeStep> of this state variable, initially set to the <CodeStep step={3}>initial state</CodeStep> you provided. +2. The <CodeStep step={2}>`set` function</CodeStep> that lets you change it to any other value in response to interaction. + +To update what’s on the screen, call the `set` function with some next state: + +```js +function handleClick() { + setName("Robin"); +} +``` + +React will store the next state, render your component again with the new values, and update the UI. + +<Pitfall> + +Calling the `set` function [**does not** change the current state in the already executing code](#ive-updated-the-state-but-logging-gives-me-the-old-value): + +```js +function handleClick() { + setName("Robin"); + console.log(name); // Still "Taylor"! +} +``` + +It only affects what `useState` will return starting from the _next_ render. + +</Pitfall> + +<Recipes titleText="Basic useState examples" titleId="examples-basic"> + +#### Counter (number) + +In this example, the `count` state variable holds a number. Clicking the button increments it. + +```js +import { useState } from "react"; + +export default function Counter() { + const [count, setCount] = useState(0); + + function handleClick() { + setCount(count + 1); + } + + return <button onClick={handleClick}>You pressed me {count} times</button>; +} +``` + +#### Text field (string) + +In this example, the `text` state variable holds a string. When you type, `handleChange` reads the latest input value from the browser input DOM element, and calls `setText` to update the state. This allows you to display the current `text` below. + +```js +import { useState } from "react"; + +export default function MyInput() { + const [text, setText] = useState("hello"); + + function handleChange(e) { + setText(e.target.value); + } + + return ( + <> + <input value={text} onChange={handleChange} /> + <p>You typed: {text}</p> + <button onClick={() => setText("hello")}>Reset</button> + </> + ); +} +``` + +#### Checkbox (boolean) + +In this example, the `liked` state variable holds a boolean. When you click the input, `setLiked` updates the `liked` state variable with whether the browser checkbox input is checked. The `liked` variable is used to render the text below the checkbox. + +```js +import { useState } from "react"; + +export default function MyCheckbox() { + const [liked, setLiked] = useState(true); + + function handleChange(e) { + setLiked(e.target.checked); + } + + return ( + <> + <label> + <input + type="checkbox" + checked={liked} + onChange={handleChange} + /> + I liked this + </label> + <p>You {liked ? "liked" : "did not like"} this.</p> + </> + ); +} +``` + +#### Form (two variables) + +You can declare more than one state variable in the same component. Each state variable is completely independent. + +```js +import { useState } from "react"; + +export default function Form() { + const [name, setName] = useState("Taylor"); + const [age, setAge] = useState(42); + + return ( + <> + <input value={name} onChange={(e) => setName(e.target.value)} /> + <button onClick={() => setAge(age + 1)}>Increment age</button> + <p> + Hello, {name}. You are {age}. + </p> + </> + ); +} +``` + +```css +button { + display: block; + margin-top: 10px; +} +``` + +</Recipes> + +--- + +### Updating state based on the previous state + +Suppose the `age` is `42`. This handler calls `setAge(age + 1)` three times: + +```js +function handleClick() { + setAge(age + 1); // setAge(42 + 1) + setAge(age + 1); // setAge(42 + 1) + setAge(age + 1); // setAge(42 + 1) +} +``` + +However, after one click, `age` will only be `43` rather than `45`! This is because calling the `set` function [does not update](/learn/state-as-a-snapshot) the `age` state variable in the already running code. So each `setAge(age + 1)` call becomes `setAge(43)`. + +To solve this problem, **you may pass an _updater function_** to `setAge` instead of the next state: + +```js +function handleClick() { + setAge((a) => a + 1); // setAge(42 => 43) + setAge((a) => a + 1); // setAge(43 => 44) + setAge((a) => a + 1); // setAge(44 => 45) +} +``` + +Here, `a => a + 1` is your updater function. It takes the <CodeStep step={1}>pending state</CodeStep> and calculates the <CodeStep step={2}>next state</CodeStep> from it. + +React puts your updater functions in a [queue.](/learn/queueing-a-series-of-state-updates) Then, during the next render, it will call them in the same order: + +1. `a => a + 1` will receive `42` as the pending state and return `43` as the next state. +1. `a => a + 1` will receive `43` as the pending state and return `44` as the next state. +1. `a => a + 1` will receive `44` as the pending state and return `45` as the next state. + +There are no other queued updates, so React will store `45` as the current state in the end. + +By convention, it's common to name the pending state argument for the first letter of the state variable name, like `a` for `age`. However, you may also call it like `prevAge` or something else that you find clearer. + +React may [call your updaters twice](#my-initializer-or-updater-function-runs-twice) in development to verify that they are [pure.](/learn/keeping-components-pure) + +<DeepDive> + +#### Is using an updater always preferred? + +You might hear a recommendation to always write code like `setAge(a => a + 1)` if the state you're setting is calculated from the previous state. There is no harm in it, but it is also not always necessary. + +In most cases, there is no difference between these two approaches. React always makes sure that for intentional user actions, like clicks, the `age` state variable would be updated before the next click. This means there is no risk of a click handler seeing a "stale" `age` at the beginning of the event handler. + +However, if you do multiple updates within the same event, updaters can be helpful. They're also helpful if accessing the state variable itself is inconvenient (you might run into this when optimizing re-renders). + +If you prefer consistency over slightly more verbose syntax, it's reasonable to always write an updater if the state you're setting is calculated from the previous state. If it's calculated from the previous state of some _other_ state variable, you might want to combine them into one object and [use a reducer.](/learn/extracting-state-logic-into-a-reducer) + +</DeepDive> + +<Recipes titleText="The difference between passing an updater and passing the next state directly" titleId="examples-updater"> + +#### Passing the updater function + +This example passes the updater function, so the "+3" button works. + +```js +import { useState } from "react"; + +export default function Counter() { + const [age, setAge] = useState(42); + + function increment() { + setAge((a) => a + 1); + } + + return ( + <> + <h1>Your age: {age}</h1> + <button + onClick={() => { + increment(); + increment(); + increment(); + }} + > + +3 + </button> + <button + onClick={() => { + increment(); + }} + > + +1 + </button> + </> + ); +} +``` + +```css +button { + display: block; + margin: 10px; + font-size: 20px; +} +h1 { + display: block; + margin: 10px; +} +``` + +#### Passing the next state directly + +This example **does not** pass the updater function, so the "+3" button **doesn't work as intended**. + +```js +import { useState } from "react"; + +export default function Counter() { + const [age, setAge] = useState(42); + + function increment() { + setAge(age + 1); + } + + return ( + <> + <h1>Your age: {age}</h1> + <button + onClick={() => { + increment(); + increment(); + increment(); + }} + > + +3 + </button> + <button + onClick={() => { + increment(); + }} + > + +1 + </button> + </> + ); +} +``` + +```css +button { + display: block; + margin: 10px; + font-size: 20px; +} +h1 { + display: block; + margin: 10px; +} +``` + +</Recipes> + +--- + +### Updating objects and arrays in state + +You can put objects and arrays into state. In React, state is considered read-only, so **you should _replace_ it rather than _mutate_ your existing objects**. For example, if you have a `form` object in state, don't mutate it: + +```js +// 🚩 Don't mutate an object in state like this: +form.firstName = "Taylor"; +``` + +Instead, replace the whole object by creating a new one: + +```js +// ✅ Replace state with a new object +setForm({ + ...form, + firstName: "Taylor", +}); +``` + +Read [updating objects in state](/learn/updating-objects-in-state) and [updating arrays in state](/learn/updating-arrays-in-state) to learn more. + +<Recipes titleText="Examples of objects and arrays in state" titleId="examples-objects"> + +#### Form (object) + +In this example, the `form` state variable holds an object. Each input has a change handler that calls `setForm` with the next state of the entire form. The `{ ...form }` spread syntax ensures that the state object is replaced rather than mutated. + +```js +import { useState } from "react"; + +export default function Form() { + const [form, setForm] = useState({ + firstName: "Barbara", + lastName: "Hepworth", + email: "bhepworth@sculpture.com", + }); + + return ( + <> + <label> + First name: + <input + value={form.firstName} + onChange={(e) => { + setForm({ + ...form, + firstName: e.target.value, + }); + }} + /> + </label> + <label> + Last name: + <input + value={form.lastName} + onChange={(e) => { + setForm({ + ...form, + lastName: e.target.value, + }); + }} + /> + </label> + <label> + Email: + <input + value={form.email} + onChange={(e) => { + setForm({ + ...form, + email: e.target.value, + }); + }} + /> + </label> + <p> + {form.firstName} {form.lastName} ({form.email}) + </p> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; +} +``` + +#### Form (nested object) + +In this example, the state is more nested. When you update nested state, you need to create a copy of the object you're updating, as well as any objects "containing" it on the way upwards. Read [updating a nested object](/learn/updating-objects-in-state#updating-a-nested-object) to learn more. + +```js +import { useState } from "react"; + +export default function Form() { + const [person, setPerson] = useState({ + name: "Niki de Saint Phalle", + artwork: { + title: "Blue Nana", + city: "Hamburg", + image: "https://i.imgur.com/Sd1AgUOm.jpg", + }, + }); + + function handleNameChange(e) { + setPerson({ + ...person, + name: e.target.value, + }); + } + + function handleTitleChange(e) { + setPerson({ + ...person, + artwork: { + ...person.artwork, + title: e.target.value, + }, + }); + } + + function handleCityChange(e) { + setPerson({ + ...person, + artwork: { + ...person.artwork, + city: e.target.value, + }, + }); + } + + function handleImageChange(e) { + setPerson({ + ...person, + artwork: { + ...person.artwork, + image: e.target.value, + }, + }); + } + + return ( + <> + <label> + Name: + <input value={person.name} onChange={handleNameChange} /> + </label> + <label> + Title: + <input + value={person.artwork.title} + onChange={handleTitleChange} + /> + </label> + <label> + City: + <input + value={person.artwork.city} + onChange={handleCityChange} + /> + </label> + <label> + Image: + <input + value={person.artwork.image} + onChange={handleImageChange} + /> + </label> + <p> + <i>{person.artwork.title}</i> + {" by "} + {person.name} + <br /> + (located in {person.artwork.city}) + </p> + <img src={person.artwork.image} alt={person.artwork.title} /> + </> + ); +} +``` + +```css +label { + display: block; +} +input { + margin-left: 5px; + margin-bottom: 5px; +} +img { + width: 200px; + height: 200px; +} +``` + +#### List (array) + +In this example, the `todos` state variable holds an array. Each button handler calls `setTodos` with the next version of that array. The `[...todos]` spread syntax, `todos.map()` and `todos.filter()` ensure the state array is replaced rather than mutated. + +```js +import { useState } from "react"; +import AddTodo from "./AddTodo.js"; +import TaskList from "./TaskList.js"; + +let nextId = 3; +const initialTodos = [ + { id: 0, title: "Buy milk", done: true }, + { id: 1, title: "Eat tacos", done: false }, + { id: 2, title: "Brew tea", done: false }, +]; + +export default function TaskApp() { + const [todos, setTodos] = useState(initialTodos); + + function handleAddTodo(title) { + setTodos([ + ...todos, + { + id: nextId++, + title: title, + done: false, + }, + ]); + } + + function handleChangeTodo(nextTodo) { + setTodos( + todos.map((t) => { + if (t.id === nextTodo.id) { + return nextTodo; + } else { + return t; + } + }) + ); + } + + function handleDeleteTodo(todoId) { + setTodos(todos.filter((t) => t.id !== todoId)); + } + + return ( + <> + <AddTodo onAddTodo={handleAddTodo} /> + <TaskList + todos={todos} + onChangeTodo={handleChangeTodo} + onDeleteTodo={handleDeleteTodo} + /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function AddTodo({ onAddTodo }) { + const [title, setTitle] = useState(""); + return ( + <> + <input + placeholder="Add todo" + value={title} + onChange={(e) => setTitle(e.target.value)} + /> + <button + onClick={() => { + setTitle(""); + onAddTodo(title); + }} + > + Add + </button> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function TaskList({ todos, onChangeTodo, onDeleteTodo }) { + return ( + <ul> + {todos.map((todo) => ( + <li key={todo.id}> + <Task + todo={todo} + onChange={onChangeTodo} + onDelete={onDeleteTodo} + /> + </li> + ))} + </ul> + ); +} + +function Task({ todo, onChange, onDelete }) { + const [isEditing, setIsEditing] = useState(false); + let todoContent; + if (isEditing) { + todoContent = ( + <> + <input + value={todo.title} + onChange={(e) => { + onChange({ + ...todo, + title: e.target.value, + }); + }} + /> + <button onClick={() => setIsEditing(false)}>Save</button> + </> + ); + } else { + todoContent = ( + <> + {todo.title} + <button onClick={() => setIsEditing(true)}>Edit</button> + </> + ); + } + return ( + <label> + <input + type="checkbox" + checked={todo.done} + onChange={(e) => { + onChange({ + ...todo, + done: e.target.checked, + }); + }} + /> + {todoContent} + <button onClick={() => onDelete(todo.id)}>Delete</button> + </label> + ); +} +``` + +```css +button { + margin: 5px; +} +li { + list-style-type: none; +} +ul, +li { + margin: 0; + padding: 0; +} +``` + +#### Writing concise update logic with Immer + +If updating arrays and objects without mutation feels tedious, you can use a library like [Immer](https://github.com/immerjs/use-immer) to reduce repetitive code. Immer lets you write concise code as if you were mutating objects, but under the hood it performs immutable updates: + +```js +import { useState } from "react"; +import { useImmer } from "use-immer"; + +let nextId = 3; +const initialList = [ + { id: 0, title: "Big Bellies", seen: false }, + { id: 1, title: "Lunar Landscape", seen: false }, + { id: 2, title: "Terracotta Army", seen: true }, +]; + +export default function BucketList() { + const [list, updateList] = useImmer(initialList); + + function handleToggle(artworkId, nextSeen) { + updateList((draft) => { + const artwork = draft.find((a) => a.id === artworkId); + artwork.seen = nextSeen; + }); + } + + return ( + <> + <h1>Art Bucket List</h1> + <h2>My list of art to see:</h2> + <ItemList artworks={list} onToggle={handleToggle} /> + </> + ); +} + +function ItemList({ artworks, onToggle }) { + return ( + <ul> + {artworks.map((artwork) => ( + <li key={artwork.id}> + <label> + <input + type="checkbox" + checked={artwork.seen} + onChange={(e) => { + onToggle(artwork.id, e.target.checked); + }} + /> + {artwork.title} + </label> + </li> + ))} + </ul> + ); +} +``` + +```json package.json +{ + "dependencies": { + "immer": "1.7.3", + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "use-immer": "0.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +</Recipes> + +--- + +### Avoiding recreating the initial state + +React saves the initial state once and ignores it on the next renders. + +```js +function TodoList() { + const [todos, setTodos] = useState(createInitialTodos()); + // ... +``` + +Although the result of `createInitialTodos()` is only used for the initial render, you're still calling this function on every render. This can be wasteful if it's creating large arrays or performing expensive calculations. + +To solve this, you may **pass it as an _initializer_ function** to `useState` instead: + +```js +function TodoList() { + const [todos, setTodos] = useState(createInitialTodos); + // ... +``` + +Notice that you’re passing `createInitialTodos`, which is the _function itself_, and not `createInitialTodos()`, which is the result of calling it. If you pass a function to `useState`, React will only call it during initialization. + +React may [call your initializers twice](#my-initializer-or-updater-function-runs-twice) in development to verify that they are [pure.](/learn/keeping-components-pure) + +<Recipes titleText="The difference between passing an initializer and passing the initial state directly" titleId="examples-initializer"> + +#### Passing the initializer function + +This example passes the initializer function, so the `createInitialTodos` function only runs during initialization. It does not run when component re-renders, such as when you type into the input. + +```js +import { useState } from "react"; + +function createInitialTodos() { + const initialTodos = []; + for (let i = 0; i < 50; i++) { + initialTodos.push({ + id: i, + text: "Item " + (i + 1), + }); + } + return initialTodos; +} + +export default function TodoList() { + const [todos, setTodos] = useState(createInitialTodos); + const [text, setText] = useState(""); + + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button + onClick={() => { + setText(""); + setTodos([ + { + id: todos.length, + text: text, + }, + ...todos, + ]); + }} + > + Add + </button> + <ul> + {todos.map((item) => ( + <li key={item.id}>{item.text}</li> + ))} + </ul> + </> + ); +} +``` + +#### Passing the initial state directly + +This example **does not** pass the initializer function, so the `createInitialTodos` function runs on every render, such as when you type into the input. There is no observable difference in behavior, but this code is less efficient. + +```js +import { useState } from "react"; + +function createInitialTodos() { + const initialTodos = []; + for (let i = 0; i < 50; i++) { + initialTodos.push({ + id: i, + text: "Item " + (i + 1), + }); + } + return initialTodos; +} + +export default function TodoList() { + const [todos, setTodos] = useState(createInitialTodos()); + const [text, setText] = useState(""); + + return ( + <> + <input value={text} onChange={(e) => setText(e.target.value)} /> + <button + onClick={() => { + setText(""); + setTodos([ + { + id: todos.length, + text: text, + }, + ...todos, + ]); + }} + > + Add + </button> + <ul> + {todos.map((item) => ( + <li key={item.id}>{item.text}</li> + ))} + </ul> + </> + ); +} +``` + +</Recipes> + +--- + +### Resetting state with a key + +You'll often encounter the `key` attribute when [rendering lists.](/learn/rendering-lists) However, it also serves another purpose. + +You can **reset a component's state by passing a different `key` to a component.** In this example, the Reset button changes the `version` state variable, which we pass as a `key` to the `Form`. When the `key` changes, React re-creates the `Form` component (and all of its children) from scratch, so its state gets reset. + +Read [preserving and resetting state](/learn/preserving-and-resetting-state) to learn more. + +```js +import { useState } from "react"; + +export default function App() { + const [version, setVersion] = useState(0); + + function handleReset() { + setVersion(version + 1); + } + + return ( + <> + <button onClick={handleReset}>Reset</button> + <Form key={version} /> + </> + ); +} + +function Form() { + const [name, setName] = useState("Taylor"); + + return ( + <> + <input value={name} onChange={(e) => setName(e.target.value)} /> + <p>Hello, {name}.</p> + </> + ); +} +``` + +```css +button { + display: block; + margin-bottom: 20px; +} +``` + +--- + +### Storing information from previous renders + +Usually, you will update state in event handlers. However, in rare cases you might want to adjust state in response to rendering -- for example, you might want to change a state variable when a prop changes. + +In most cases, you don't need this: + +- **If the value you need can be computed entirely from the current props or other state, [remove that redundant state altogether.](/learn/choosing-the-state-structure#avoid-redundant-state)** If you're worried about recomputing too often, the [`useMemo` Hook](/reference/react/useMemo) can help. +- If you want to reset the entire component tree's state, [pass a different `key` to your component.](#resetting-state-with-a-key) +- If you can, update all the relevant state in the event handlers. + +In the rare case that none of these apply, there is a pattern you can use to update state based on the values that have been rendered so far, by calling a `set` function while your component is rendering. + +Here's an example. This `CountLabel` component displays the `count` prop passed to it: + +```js +export default function CountLabel({ count }) { + return <h1>{count}</h1>; +} +``` + +Say you want to show whether the counter has _increased or decreased_ since the last change. The `count` prop doesn't tell you this -- you need to keep track of its previous value. Add the `prevCount` state variable to track it. Add another state variable called `trend` to hold whether the count has increased or decreased. Compare `prevCount` with `count`, and if they're not equal, update both `prevCount` and `trend`. Now you can show both the current count prop and _how it has changed since the last render_. + +```js +import { useState } from "react"; +import CountLabel from "./CountLabel.js"; + +export default function App() { + const [count, setCount] = useState(0); + return ( + <> + <button onClick={() => setCount(count + 1)}>Increment</button> + <button onClick={() => setCount(count - 1)}>Decrement</button> + <CountLabel count={count} /> + </> + ); +} +``` + +```js +import { useState } from "react"; + +export default function CountLabel({ count }) { + const [prevCount, setPrevCount] = useState(count); + const [trend, setTrend] = useState(null); + if (prevCount !== count) { + setPrevCount(count); + setTrend(count > prevCount ? "increasing" : "decreasing"); + } + return ( + <> + <h1>{count}</h1> + {trend && <p>The count is {trend}</p>} + </> + ); +} +``` + +```css +button { + margin-bottom: 10px; +} +``` + +Note that if you call a `set` function while rendering, it must be inside a condition like `prevCount !== count`, and there must be a call like `setPrevCount(count)` inside of the condition. Otherwise, your component would re-render in a loop until it crashes. Also, you can only update the state of the _currently rendering_ component like this. Calling the `set` function of _another_ component during rendering is an error. Finally, your `set` call should still [update state without mutation](#updating-objects-and-arrays-in-state) -- this doesn't mean you can break other rules of [pure functions.](/learn/keeping-components-pure) + +This pattern can be hard to understand and is usually best avoided. However, it's better than updating state in an effect. When you call the `set` function during render, React will re-render that component immediately after your component exits with a `return` statement, and before rendering the children. This way, children don't need to render twice. The rest of your component function will still execute (and the result will be thrown away). If your condition is below all the Hook calls, you may add an early `return;` to restart rendering earlier. + +--- + +## Troubleshooting + +### I've updated the state, but logging gives me the old value + +Calling the `set` function **does not change state in the running code**: + +```js +function handleClick() { + console.log(count); // 0 + + setCount(count + 1); // Request a re-render with 1 + console.log(count); // Still 0! + + setTimeout(() => { + console.log(count); // Also 0! + }, 5000); +} +``` + +This is because [states behaves like a snapshot.](/learn/state-as-a-snapshot) Updating state requests another render with the new state value, but does not affect the `count` JavaScript variable in your already-running event handler. + +If you need to use the next state, you can save it in a variable before passing it to the `set` function: + +```js +const nextCount = count + 1; +setCount(nextCount); + +console.log(count); // 0 +console.log(nextCount); // 1 +``` + +--- + +### I've updated the state, but the screen doesn't update + +React will **ignore your update if the next state is equal to the previous state,** as determined by an [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. This usually happens when you change an object or an array in state directly: + +```js +obj.x = 10; // 🚩 Wrong: mutating existing object +setObj(obj); // 🚩 Doesn't do anything +``` + +You mutated an existing `obj` object and passed it back to `setObj`, so React ignored the update. To fix this, you need to ensure that you're always [_replacing_ objects and arrays in state instead of _mutating_ them](#updating-objects-and-arrays-in-state): + +```js +// ✅ Correct: creating a new object +setObj({ + ...obj, + x: 10, +}); +``` + +--- + +### I'm getting an error: "Too many re-renders" + +You might get an error that says: `Too many re-renders. React limits the number of renders to prevent an infinite loop.` Typically, this means that you're unconditionally setting state _during render_, so your component enters a loop: render, set state (which causes a render), render, set state (which causes a render), and so on. Very often, this is caused by a mistake in specifying an event handler: + +```js +// 🚩 Wrong: calls the handler during render +return <button onClick={handleClick()}>Click me</button>; + +// ✅ Correct: passes down the event handler +return <button onClick={handleClick}>Click me</button>; + +// ✅ Correct: passes down an inline function +return <button onClick={(e) => handleClick(e)}>Click me</button>; +``` + +If you can't find the cause of this error, click on the arrow next to the error in the console and look through the JavaScript stack to find the specific `set` function call responsible for the error. + +--- + +### My initializer or updater function runs twice + +In [Strict Mode](/reference/react/StrictMode), React will call some of your functions twice instead of once: + +```js +function TodoList() { + // This component function will run twice for every render. + + const [todos, setTodos] = useState(() => { + // This initializer function will run twice during initialization. + return createTodos(); + }); + + function handleClick() { + setTodos(prevTodos => { + // This updater function will run twice for every click. + return [...prevTodos, createTodo()]; + }); + } + // ... +``` + +This is expected and shouldn't break your code. + +This **development-only** behavior helps you [keep components pure.](/learn/keeping-components-pure) React uses the result of one of the calls, and ignores the result of the other call. As long as your component, initializer, and updater functions are pure, this shouldn't affect your logic. However, if they are accidentally impure, this helps you notice the mistakes. + +For example, this impure updater function mutates an array in state: + +```js +setTodos((prevTodos) => { + // 🚩 Mistake: mutating state + prevTodos.push(createTodo()); +}); +``` + +Because React calls your updater function twice, you'll see the todo was added twice, so you'll know that there is a mistake. In this example, you can fix the mistake by [replacing the array instead of mutating it](#updating-objects-and-arrays-in-state): + +```js +setTodos((prevTodos) => { + // ✅ Correct: replacing with new state + return [...prevTodos, createTodo()]; +}); +``` + +Now that this updater function is pure, calling it an extra time doesn't make a difference in behavior. This is why React calling it twice helps you find mistakes. **Only component, initializer, and updater functions need to be pure.** Event handlers don't need to be pure, so React will never call your event handlers twice. + +Read [keeping components pure](/learn/keeping-components-pure) to learn more. + +--- + +### I'm trying to set state to a function, but it gets called instead + +You can't put a function into state like this: + +```js +const [fn, setFn] = useState(someFunction); + +function handleClick() { + setFn(someOtherFunction); +} +``` + +Because you're passing a function, React assumes that `someFunction` is an [initializer function](#avoiding-recreating-the-initial-state), and that `someOtherFunction` is an [updater function](#updating-state-based-on-the-previous-state), so it tries to call them and store the result. To actually _store_ a function, you have to put `() =>` before them in both cases. Then React will store the functions you pass. + +```js +const [fn, setFn] = useState(() => someFunction); + +function handleClick() { + setFn(() => someOtherFunction); +} +``` diff --git a/docs/src/reference/use-sync-external-store.md b/docs/src/reference/use-sync-external-store.md new file mode 100644 index 000000000..3b24dbfe8 --- /dev/null +++ b/docs/src/reference/use-sync-external-store.md @@ -0,0 +1,429 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. +<!-- +## Overview + +<p class="intro" markdown> + +`useSyncExternalStore` is a React Hook that lets you subscribe to an external store. + +```js +const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) +``` + +</p> + +--- + +## Reference + +### `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)` + +Call `useSyncExternalStore` at the top level of your component to read a value from an external data store. + +```js +import { useSyncExternalStore } from "react"; +import { todosStore } from "./todoStore.js"; + +function TodosApp() { + const todos = useSyncExternalStore( + todosStore.subscribe, + todosStore.getSnapshot + ); + // ... +} +``` + +It returns the snapshot of the data in the store. You need to pass two functions as arguments: + +1. The `subscribe` function should subscribe to the store and return a function that unsubscribes. +2. The `getSnapshot` function should read a snapshot of the data from the store. + +[See more examples below.](#usage) + +#### Parameters + +- `subscribe`: A function that takes a single `callback` argument and subscribes it to the store. When the store changes, it should invoke the provided `callback`. This will cause the component to re-render. The `subscribe` function should return a function that cleans up the subscription. + +- `getSnapshot`: A function that returns a snapshot of the data in the store that's needed by the component. While the store has not changed, repeated calls to `getSnapshot` must return the same value. If the store changes and the returned value is different (as compared by [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)), React re-renders the component. + +- **optional** `getServerSnapshot`: A function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server, and is usually serialized and passed from the server to the client. If you omit this argument, rendering the component on the server will throw an error. + +#### Returns + +The current snapshot of the store which you can use in your rendering logic. + +#### Caveats + +- The store snapshot returned by `getSnapshot` must be immutable. If the underlying store has mutable data, return a new immutable snapshot if the data has changed. Otherwise, return a cached last snapshot. + +- If a different `subscribe` function is passed during a re-render, React will re-subscribe to the store using the newly passed `subscribe` function. You can prevent this by declaring `subscribe` outside the component. + +--- + +## Usage + +### Subscribing to an external store + +Most of your React components will only read data from their [props,](/learn/passing-props-to-a-component) [state,](/reference/react/useState) and [context.](/reference/react/useContext) However, sometimes a component needs to read some data from some store outside of React that changes over time. This includes: + +- Third-party state management libraries that hold state outside of React. +- Browser APIs that expose a mutable value and events to subscribe to its changes. + +Call `useSyncExternalStore` at the top level of your component to read a value from an external data store. + +```js +import { useSyncExternalStore } from "react"; +import { todosStore } from "./todoStore.js"; + +function TodosApp() { + const todos = useSyncExternalStore( + todosStore.subscribe, + todosStore.getSnapshot + ); + // ... +} +``` + +It returns the <CodeStep step={3}>snapshot</CodeStep> of the data in the store. You need to pass two functions as arguments: + +1. The <CodeStep step={1}>`subscribe` function</CodeStep> should subscribe to the store and return a function that unsubscribes. +2. The <CodeStep step={2}>`getSnapshot` function</CodeStep> should read a snapshot of the data from the store. + +React will use these functions to keep your component subscribed to the store and re-render it on changes. + +For example, in the sandbox below, `todosStore` is implemented as an external store that stores data outside of React. The `TodosApp` component connects to that external store with the `useSyncExternalStore` Hook. + +```js +import { useSyncExternalStore } from "react"; +import { todosStore } from "./todoStore.js"; + +export default function TodosApp() { + const todos = useSyncExternalStore( + todosStore.subscribe, + todosStore.getSnapshot + ); + return ( + <> + <button onClick={() => todosStore.addTodo()}>Add todo</button> + <hr /> + <ul> + {todos.map((todo) => ( + <li key={todo.id}>{todo.text}</li> + ))} + </ul> + </> + ); +} +``` + +```js +// This is an example of a third-party store +// that you might need to integrate with React. + +// If your app is fully built with React, +// we recommend using React state instead. + +let nextId = 0; +let todos = [{ id: nextId++, text: "Todo #1" }]; +let listeners = []; + +export const todosStore = { + addTodo() { + todos = [...todos, { id: nextId++, text: "Todo #" + nextId }]; + emitChange(); + }, + subscribe(listener) { + listeners = [...listeners, listener]; + return () => { + listeners = listeners.filter((l) => l !== listener); + }; + }, + getSnapshot() { + return todos; + }, +}; + +function emitChange() { + for (let listener of listeners) { + listener(); + } +} +``` + +<Note> + +When possible, we recommend using built-in React state with [`useState`](/reference/react/useState) and [`useReducer`](/reference/react/useReducer) instead. The `useSyncExternalStore` API is mostly useful if you need to integrate with existing non-React code. + +</Note> + +--- + +### Subscribing to a browser API + +Another reason to add `useSyncExternalStore` is when you want to subscribe to some value exposed by the browser that changes over time. For example, suppose that you want your component to display whether the network connection is active. The browser exposes this information via a property called [`navigator.onLine`.](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine) + +This value can change without React's knowledge, so you should read it with `useSyncExternalStore`. + +```js +import { useSyncExternalStore } from "react"; + +function ChatIndicator() { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + // ... +} +``` + +To implement the `getSnapshot` function, read the current value from the browser API: + +```js +function getSnapshot() { + return navigator.onLine; +} +``` + +Next, you need to implement the `subscribe` function. For example, when `navigator.onLine` changes, the browser fires the [`online`](https://developer.mozilla.org/en-US/docs/Web/API/Window/online_event) and [`offline`](https://developer.mozilla.org/en-US/docs/Web/API/Window/offline_event) events on the `window` object. You need to subscribe the `callback` argument to the corresponding events, and then return a function that cleans up the subscriptions: + +```js +function subscribe(callback) { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} +``` + +Now React knows how to read the value from the external `navigator.onLine` API and how to subscribe to its changes. Disconnect your device from the network and notice that the component re-renders in response: + +```js +import { useSyncExternalStore } from "react"; + +export default function ChatIndicator() { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +function getSnapshot() { + return navigator.onLine; +} + +function subscribe(callback) { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} +``` + +--- + +### Extracting the logic to a custom Hook + +Usually you won't write `useSyncExternalStore` directly in your components. Instead, you'll typically call it from your own custom Hook. This lets you use the same external store from different components. + +For example, this custom `useOnlineStatus` Hook tracks whether the network is online: + +```js +import { useSyncExternalStore } from "react"; + +export function useOnlineStatus() { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + return isOnline; +} + +function getSnapshot() { + // ... +} + +function subscribe(callback) { + // ... +} +``` + +Now different components can call `useOnlineStatus` without repeating the underlying implementation: + +```js +import { useOnlineStatus } from "./useOnlineStatus.js"; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log("✅ Progress saved"); + } + + return ( + <button disabled={!isOnline} onClick={handleSaveClick}> + {isOnline ? "Save progress" : "Reconnecting..."} + </button> + ); +} + +export default function App() { + return ( + <> + <SaveButton /> + <StatusBar /> + </> + ); +} +``` + +```js +import { useSyncExternalStore } from "react"; + +export function useOnlineStatus() { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + return isOnline; +} + +function getSnapshot() { + return navigator.onLine; +} + +function subscribe(callback) { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} +``` + +--- + +### Adding support for server rendering + +If your React app uses [server rendering,](/reference/react-dom/server) your React components will also run outside the browser environment to generate the initial HTML. This creates a few challenges when connecting to an external store: + +- If you're connecting to a browser-only API, it won't work because it does not exist on the server. +- If you're connecting to a third-party data store, you'll need its data to match between the server and client. + +To solve these issues, pass a `getServerSnapshot` function as the third argument to `useSyncExternalStore`: + +```js +import { useSyncExternalStore } from "react"; + +export function useOnlineStatus() { + const isOnline = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot + ); + return isOnline; +} + +function getSnapshot() { + return navigator.onLine; +} + +function getServerSnapshot() { + return true; // Always show "Online" for server-generated HTML +} + +function subscribe(callback) { + // ... +} +``` + +The `getServerSnapshot` function is similar to `getSnapshot`, but it runs only in two situations: + +- It runs on the server when generating the HTML. +- It runs on the client during [hydration](/reference/react-dom/client/hydrateRoot), i.e. when React takes the server HTML and makes it interactive. + +This lets you provide the initial snapshot value which will be used before the app becomes interactive. If there is no meaningful initial value for the server rendering, omit this argument to [force rendering on the client.](/reference/react/Suspense#providing-a-fallback-for-server-errors-and-server-only-content) + +<Note> + +Make sure that `getServerSnapshot` returns the same exact data on the initial client render as it returned on the server. For example, if `getServerSnapshot` returned some prepopulated store content on the server, you need to transfer this content to the client. One way to do this is to emit a `<script>` tag during server rendering that sets a global like `window.MY_STORE_DATA`, and read from that global on the client in `getServerSnapshot`. Your external store should provide instructions on how to do that. + +</Note> + +--- + +## Troubleshooting + +### I'm getting an error: "The result of `getSnapshot` should be cached" + +This error means your `getSnapshot` function returns a new object every time it's called, for example: + +```js +function getSnapshot() { + // 🔴 Do not return always different objects from getSnapshot + return { + todos: myStore.todos, + }; +} +``` + +React will re-render the component if `getSnapshot` return value is different from the last time. This is why, if you always return a different value, you will enter an infinite loop and get this error. + +Your `getSnapshot` object should only return a different object if something has actually changed. If your store contains immutable data, you can return that data directly: + +```js +function getSnapshot() { + // ✅ You can return immutable data + return myStore.todos; +} +``` + +If your store data is mutable, your `getSnapshot` function should return an immutable snapshot of it. This means it _does_ need to create new objects, but it shouldn't do this for every single call. Instead, it should store the last calculated snapshot, and return the same snapshot as the last time if the data in the store has not changed. How you determine whether mutable data has changed depends on your mutable store. + +--- + +### My `subscribe` function gets called after every re-render + +This `subscribe` function is defined _inside_ a component so it is different on every re-render: + +```js +function ChatIndicator() { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + + // 🚩 Always a different function, so React will resubscribe on every re-render + function subscribe() { + // ... + } + + // ... +} +``` + +React will resubscribe to your store if you pass a different `subscribe` function between re-renders. If this causes performance issues and you'd like to avoid resubscribing, move the `subscribe` function outside: + +```js +function ChatIndicator() { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + // ... +} + +// ✅ Always the same function, so React won't need to resubscribe +function subscribe() { + // ... +} +``` + +Alternatively, wrap `subscribe` into [`useCallback`](/reference/react/useCallback) to only resubscribe when some argument changes: + +```js +function ChatIndicator({ userId }) { + const isOnline = useSyncExternalStore(subscribe, getSnapshot); + + // ✅ Same function as long as userId doesn't change + const subscribe = useCallback(() => { + // ... + }, [userId]); + + // ... +} +``` --> diff --git a/docs/src/reference/use-transition.md b/docs/src/reference/use-transition.md new file mode 100644 index 000000000..a0930af11 --- /dev/null +++ b/docs/src/reference/use-transition.md @@ -0,0 +1,1587 @@ +!!! warning "Planned / Undeveloped" + + This feature is planned, but not yet developed. +<!-- +## Overview + +<p class="intro" markdown> + +`useTransition` is a React Hook that lets you update the state without blocking the UI. + +```js +const [isPending, startTransition] = useTransition(); +``` + +</p> + +--- + +## Reference + +### `useTransition()` + +Call `useTransition` at the top level of your component to mark some state updates as transitions. + +```js +import { useTransition } from "react"; + +function TabContainer() { + const [isPending, startTransition] = useTransition(); + // ... +} +``` + +[See more examples below.](#usage) + +#### Parameters + +`useTransition` does not take any parameters. + +#### Returns + +`useTransition` returns an array with exactly two items: + +1. The `isPending` flag that tells you whether there is a pending transition. +2. The [`startTransition` function](#starttransition) that lets you mark a state update as a transition. + +--- + +### `startTransition` function + +The `startTransition` function returned by `useTransition` lets you mark a state update as a transition. + +```js +function TabContainer() { + const [isPending, startTransition] = useTransition(); + const [tab, setTab] = useState("about"); + + function selectTab(nextTab) { + startTransition(() => { + setTab(nextTab); + }); + } + // ... +} +``` + +#### Parameters + +- `scope`: A function that updates some state by calling one or more [`set` functions.](/reference/react/useState#setstate) React immediately calls `scope` with no parameters and marks all state updates scheduled synchronously during the `scope` function call as transitions. They will be [non-blocking](#marking-a-state-update-as-a-non-blocking-transition) and [will not display unwanted loading indicators.](#preventing-unwanted-loading-indicators) + +#### Returns + +`startTransition` does not return anything. + +#### Caveats + +- `useTransition` is a Hook, so it can only be called inside components or custom Hooks. If you need to start a transition somewhere else (for example, from a data library), call the standalone [`startTransition`](/reference/react/startTransition) instead. + +- You can wrap an update into a transition only if you have access to the `set` function of that state. If you want to start a transition in response to some prop or a custom Hook value, try [`useDeferredValue`](/reference/react/useDeferredValue) instead. + +- The function you pass to `startTransition` must be synchronous. React immediately executes this function, marking all state updates that happen while it executes as transitions. If you try to perform more state updates later (for example, in a timeout), they won't be marked as transitions. + +- A state update marked as a transition will be interrupted by other state updates. For example, if you update a chart component inside a transition, but then start typing into an input while the chart is in the middle of a re-render, React will restart the rendering work on the chart component after handling the input update. + +- Transition updates can't be used to control text inputs. + +- If there are multiple ongoing transitions, React currently batches them together. This is a limitation that will likely be removed in a future release. + +--- + +## Usage + +### Marking a state update as a non-blocking transition + +Call `useTransition` at the top level of your component to mark state updates as non-blocking _transitions_. + +```js +import { useState, useTransition } from "react"; + +function TabContainer() { + const [isPending, startTransition] = useTransition(); + // ... +} +``` + +`useTransition` returns an array with exactly two items: + +1. The <CodeStep step={1}>`isPending` flag</CodeStep> that tells you whether there is a pending transition. +2. The <CodeStep step={2}>`startTransition` function</CodeStep> that lets you mark a state update as a transition. + +You can then mark a state update as a transition like this: + +```js +function TabContainer() { + const [isPending, startTransition] = useTransition(); + const [tab, setTab] = useState("about"); + + function selectTab(nextTab) { + startTransition(() => { + setTab(nextTab); + }); + } + // ... +} +``` + +Transitions let you keep the user interface updates responsive even on slow devices. + +With a transition, your UI stays responsive in the middle of a re-render. For example, if the user clicks a tab but then change their mind and click another tab, they can do that without waiting for the first re-render to finish. + +<Recipes titleText="The difference between useTransition and regular state updates" titleId="examples"> + +#### Updating the current tab in a transition + +In this example, the "Posts" tab is **artificially slowed down** so that it takes at least a second to render. + +Click "Posts" and then immediately click "Contact". Notice that this interrupts the slow render of "Posts". The "Contact" tab shows immediately. Because this state update is marked as a transition, a slow re-render did not freeze the user interface. + +```js +import { useState, useTransition } from "react"; +import TabButton from "./TabButton.js"; +import AboutTab from "./AboutTab.js"; +import PostsTab from "./PostsTab.js"; +import ContactTab from "./ContactTab.js"; + +export default function TabContainer() { + const [isPending, startTransition] = useTransition(); + const [tab, setTab] = useState("about"); + + function selectTab(nextTab) { + startTransition(() => { + setTab(nextTab); + }); + } + + return ( + <> + <TabButton + isActive={tab === "about"} + onClick={() => selectTab("about")} + > + About + </TabButton> + <TabButton + isActive={tab === "posts"} + onClick={() => selectTab("posts")} + > + Posts (slow) + </TabButton> + <TabButton + isActive={tab === "contact"} + onClick={() => selectTab("contact")} + > + Contact + </TabButton> + <hr /> + {tab === "about" && <AboutTab />} + {tab === "posts" && <PostsTab />} + {tab === "contact" && <ContactTab />} + </> + ); +} +``` + +```js +import { useTransition } from "react"; + +export default function TabButton({ children, isActive, onClick }) { + if (isActive) { + return <b>{children}</b>; + } + return ( + <button + onClick={() => { + onClick(); + }} + > + {children} + </button> + ); +} +``` + +```js +export default function AboutTab() { + return <p>Welcome to my profile!</p>; +} +``` + +```js +import { memo } from "react"; + +const PostsTab = memo(function PostsTab() { + // Log once. The actual slowdown is inside SlowPost. + console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />"); + + let items = []; + for (let i = 0; i < 500; i++) { + items.push(<SlowPost key={i} index={i} />); + } + return <ul className="items">{items}</ul>; +}); + +function SlowPost({ index }) { + let startTime = performance.now(); + while (performance.now() - startTime < 1) { + // Do nothing for 1 ms per item to emulate extremely slow code + } + + return <li className="item">Post #{index + 1}</li>; +} + +export default PostsTab; +``` + +```js +export default function ContactTab() { + return ( + <> + <p>You can find me online here:</p> + <ul> + <li>admin@mysite.com</li> + <li>+123456789</li> + </ul> + </> + ); +} +``` + +```css +button { + margin-right: 10px; +} +b { + display: inline-block; + margin-right: 10px; +} +``` + +#### Updating the current tab without a transition + +In this example, the "Posts" tab is also **artificially slowed down** so that it takes at least a second to render. Unlike in the previous example, this state update is **not a transition.** + +Click "Posts" and then immediately click "Contact". Notice that the app freezes while rendering the slowed down tab, and the UI becomes unresponsive. This state update is not a transition, so a slow re-render freezed the user interface. + +```js +import { useState } from "react"; +import TabButton from "./TabButton.js"; +import AboutTab from "./AboutTab.js"; +import PostsTab from "./PostsTab.js"; +import ContactTab from "./ContactTab.js"; + +export default function TabContainer() { + const [tab, setTab] = useState("about"); + + function selectTab(nextTab) { + setTab(nextTab); + } + + return ( + <> + <TabButton + isActive={tab === "about"} + onClick={() => selectTab("about")} + > + About + </TabButton> + <TabButton + isActive={tab === "posts"} + onClick={() => selectTab("posts")} + > + Posts (slow) + </TabButton> + <TabButton + isActive={tab === "contact"} + onClick={() => selectTab("contact")} + > + Contact + </TabButton> + <hr /> + {tab === "about" && <AboutTab />} + {tab === "posts" && <PostsTab />} + {tab === "contact" && <ContactTab />} + </> + ); +} +``` + +```js +import { useTransition } from "react"; + +export default function TabButton({ children, isActive, onClick }) { + if (isActive) { + return <b>{children}</b>; + } + return ( + <button + onClick={() => { + onClick(); + }} + > + {children} + </button> + ); +} +``` + +```js +export default function AboutTab() { + return <p>Welcome to my profile!</p>; +} +``` + +```js +import { memo } from "react"; + +const PostsTab = memo(function PostsTab() { + // Log once. The actual slowdown is inside SlowPost. + console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />"); + + let items = []; + for (let i = 0; i < 500; i++) { + items.push(<SlowPost key={i} index={i} />); + } + return <ul className="items">{items}</ul>; +}); + +function SlowPost({ index }) { + let startTime = performance.now(); + while (performance.now() - startTime < 1) { + // Do nothing for 1 ms per item to emulate extremely slow code + } + + return <li className="item">Post #{index + 1}</li>; +} + +export default PostsTab; +``` + +```js +export default function ContactTab() { + return ( + <> + <p>You can find me online here:</p> + <ul> + <li>admin@mysite.com</li> + <li>+123456789</li> + </ul> + </> + ); +} +``` + +```css +button { + margin-right: 10px; +} +b { + display: inline-block; + margin-right: 10px; +} +``` + +</Recipes> + +--- + +### Updating the parent component in a transition + +You can update a parent component's state from the `useTransition` call, too. For example, this `TabButton` component wraps its `onClick` logic in a transition: + +```js +export default function TabButton({ children, isActive, onClick }) { + const [isPending, startTransition] = useTransition(); + if (isActive) { + return <b>{children}</b>; + } + return ( + <button + onClick={() => { + startTransition(() => { + onClick(); + }); + }} + > + {children} + </button> + ); +} +``` + +Because the parent component updates its state inside the `onClick` event handler, that state update gets marked as a transition. This is why, like in the earlier example, you can click on "Posts" and then immediately click "Contact". Updating the selected tab is marked as a transition, so it does not block user interactions. + +```js +import { useState } from "react"; +import TabButton from "./TabButton.js"; +import AboutTab from "./AboutTab.js"; +import PostsTab from "./PostsTab.js"; +import ContactTab from "./ContactTab.js"; + +export default function TabContainer() { + const [tab, setTab] = useState("about"); + return ( + <> + <TabButton + isActive={tab === "about"} + onClick={() => setTab("about")} + > + About + </TabButton> + <TabButton + isActive={tab === "posts"} + onClick={() => setTab("posts")} + > + Posts (slow) + </TabButton> + <TabButton + isActive={tab === "contact"} + onClick={() => setTab("contact")} + > + Contact + </TabButton> + <hr /> + {tab === "about" && <AboutTab />} + {tab === "posts" && <PostsTab />} + {tab === "contact" && <ContactTab />} + </> + ); +} +``` + +```js +import { useTransition } from "react"; + +export default function TabButton({ children, isActive, onClick }) { + const [isPending, startTransition] = useTransition(); + if (isActive) { + return <b>{children}</b>; + } + return ( + <button + onClick={() => { + startTransition(() => { + onClick(); + }); + }} + > + {children} + </button> + ); +} +``` + +```js +export default function AboutTab() { + return <p>Welcome to my profile!</p>; +} +``` + +```js +import { memo } from "react"; + +const PostsTab = memo(function PostsTab() { + // Log once. The actual slowdown is inside SlowPost. + console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />"); + + let items = []; + for (let i = 0; i < 500; i++) { + items.push(<SlowPost key={i} index={i} />); + } + return <ul className="items">{items}</ul>; +}); + +function SlowPost({ index }) { + let startTime = performance.now(); + while (performance.now() - startTime < 1) { + // Do nothing for 1 ms per item to emulate extremely slow code + } + + return <li className="item">Post #{index + 1}</li>; +} + +export default PostsTab; +``` + +```js +export default function ContactTab() { + return ( + <> + <p>You can find me online here:</p> + <ul> + <li>admin@mysite.com</li> + <li>+123456789</li> + </ul> + </> + ); +} +``` + +```css +button { + margin-right: 10px; +} +b { + display: inline-block; + margin-right: 10px; +} +``` + +--- + +### Displaying a pending visual state during the transition + +You can use the `isPending` boolean value returned by `useTransition` to indicate to the user that a transition is in progress. For example, the tab button can have a special "pending" visual state: + +```js +function TabButton({ children, isActive, onClick }) { + const [isPending, startTransition] = useTransition(); + // ... + if (isPending) { + return <b className="pending">{children}</b>; + } + // ... +``` + +Notice how clicking "Posts" now feels more responsive because the tab button itself updates right away: + +```js +import { useState } from "react"; +import TabButton from "./TabButton.js"; +import AboutTab from "./AboutTab.js"; +import PostsTab from "./PostsTab.js"; +import ContactTab from "./ContactTab.js"; + +export default function TabContainer() { + const [tab, setTab] = useState("about"); + return ( + <> + <TabButton + isActive={tab === "about"} + onClick={() => setTab("about")} + > + About + </TabButton> + <TabButton + isActive={tab === "posts"} + onClick={() => setTab("posts")} + > + Posts (slow) + </TabButton> + <TabButton + isActive={tab === "contact"} + onClick={() => setTab("contact")} + > + Contact + </TabButton> + <hr /> + {tab === "about" && <AboutTab />} + {tab === "posts" && <PostsTab />} + {tab === "contact" && <ContactTab />} + </> + ); +} +``` + +```js +import { useTransition } from "react"; + +export default function TabButton({ children, isActive, onClick }) { + const [isPending, startTransition] = useTransition(); + if (isActive) { + return <b>{children}</b>; + } + if (isPending) { + return <b className="pending">{children}</b>; + } + return ( + <button + onClick={() => { + startTransition(() => { + onClick(); + }); + }} + > + {children} + </button> + ); +} +``` + +```js +export default function AboutTab() { + return <p>Welcome to my profile!</p>; +} +``` + +```js +import { memo } from "react"; + +const PostsTab = memo(function PostsTab() { + // Log once. The actual slowdown is inside SlowPost. + console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />"); + + let items = []; + for (let i = 0; i < 500; i++) { + items.push(<SlowPost key={i} index={i} />); + } + return <ul className="items">{items}</ul>; +}); + +function SlowPost({ index }) { + let startTime = performance.now(); + while (performance.now() - startTime < 1) { + // Do nothing for 1 ms per item to emulate extremely slow code + } + + return <li className="item">Post #{index + 1}</li>; +} + +export default PostsTab; +``` + +```js +export default function ContactTab() { + return ( + <> + <p>You can find me online here:</p> + <ul> + <li>admin@mysite.com</li> + <li>+123456789</li> + </ul> + </> + ); +} +``` + +```css +button { + margin-right: 10px; +} +b { + display: inline-block; + margin-right: 10px; +} +.pending { + color: #777; +} +``` + +--- + +### Preventing unwanted loading indicators + +In this example, the `PostsTab` component fetches some data using a [Suspense-enabled](/reference/react/Suspense) data source. When you click the "Posts" tab, the `PostsTab` component _suspends_, causing the closest loading fallback to appear: + +```js +import { Suspense, useState } from "react"; +import TabButton from "./TabButton.js"; +import AboutTab from "./AboutTab.js"; +import PostsTab from "./PostsTab.js"; +import ContactTab from "./ContactTab.js"; + +export default function TabContainer() { + const [tab, setTab] = useState("about"); + return ( + <Suspense fallback={<h1>🌀 Loading...</h1>}> + <TabButton + isActive={tab === "about"} + onClick={() => setTab("about")} + > + About + </TabButton> + <TabButton + isActive={tab === "posts"} + onClick={() => setTab("posts")} + > + Posts + </TabButton> + <TabButton + isActive={tab === "contact"} + onClick={() => setTab("contact")} + > + Contact + </TabButton> + <hr /> + {tab === "about" && <AboutTab />} + {tab === "posts" && <PostsTab />} + {tab === "contact" && <ContactTab />} + </Suspense> + ); +} +``` + +```js +export default function TabButton({ children, isActive, onClick }) { + if (isActive) { + return <b>{children}</b>; + } + return ( + <button + onClick={() => { + onClick(); + }} + > + {children} + </button> + ); +} +``` + +```js +export default function AboutTab() { + return <p>Welcome to my profile!</p>; +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +function PostsTab() { + const posts = use(fetchData("/posts")); + return ( + <ul className="items"> + {posts.map((post) => ( + <Post key={post.id} title={post.title} /> + ))} + </ul> + ); +} + +function Post({ title }) { + return <li className="item">{title}</li>; +} + +export default PostsTab; + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +export default function ContactTab() { + return ( + <> + <p>You can find me online here:</p> + <ul> + <li>admin@mysite.com</li> + <li>+123456789</li> + </ul> + </> + ); +} +``` + +```js +// Note: the way you would do data fetching depends on +// the framework that you use together with Suspense. +// Normally, the caching logic would be inside a framework. + +let cache = new Map(); + +export function fetchData(url) { + if (!cache.has(url)) { + cache.set(url, getData(url)); + } + return cache.get(url); +} + +async function getData(url) { + if (url.startsWith("/posts")) { + return await getPosts(); + } else { + throw Error("Not implemented"); + } +} + +async function getPosts() { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + let posts = []; + for (let i = 0; i < 500; i++) { + posts.push({ + id: i, + title: "Post #" + (i + 1), + }); + } + return posts; +} +``` + +```css +button { + margin-right: 10px; +} +b { + display: inline-block; + margin-right: 10px; +} +.pending { + color: #777; +} +``` + +Hiding the entire tab container to show a loading indicator leads to a jarring user experience. If you add `useTransition` to `TabButton`, you can instead indicate display the pending state in the tab button instead. + +Notice that clicking "Posts" no longer replaces the entire tab container with a spinner: + +```js +import { Suspense, useState } from "react"; +import TabButton from "./TabButton.js"; +import AboutTab from "./AboutTab.js"; +import PostsTab from "./PostsTab.js"; +import ContactTab from "./ContactTab.js"; + +export default function TabContainer() { + const [tab, setTab] = useState("about"); + return ( + <Suspense fallback={<h1>🌀 Loading...</h1>}> + <TabButton + isActive={tab === "about"} + onClick={() => setTab("about")} + > + About + </TabButton> + <TabButton + isActive={tab === "posts"} + onClick={() => setTab("posts")} + > + Posts + </TabButton> + <TabButton + isActive={tab === "contact"} + onClick={() => setTab("contact")} + > + Contact + </TabButton> + <hr /> + {tab === "about" && <AboutTab />} + {tab === "posts" && <PostsTab />} + {tab === "contact" && <ContactTab />} + </Suspense> + ); +} +``` + +```js +import { useTransition } from "react"; + +export default function TabButton({ children, isActive, onClick }) { + const [isPending, startTransition] = useTransition(); + if (isActive) { + return <b>{children}</b>; + } + if (isPending) { + return <b className="pending">{children}</b>; + } + return ( + <button + onClick={() => { + startTransition(() => { + onClick(); + }); + }} + > + {children} + </button> + ); +} +``` + +```js +export default function AboutTab() { + return <p>Welcome to my profile!</p>; +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +function PostsTab() { + const posts = use(fetchData("/posts")); + return ( + <ul className="items"> + {posts.map((post) => ( + <Post key={post.id} title={post.title} /> + ))} + </ul> + ); +} + +function Post({ title }) { + return <li className="item">{title}</li>; +} + +export default PostsTab; + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +export default function ContactTab() { + return ( + <> + <p>You can find me online here:</p> + <ul> + <li>admin@mysite.com</li> + <li>+123456789</li> + </ul> + </> + ); +} +``` + +```js +// Note: the way you would do data fetching depends on +// the framework that you use together with Suspense. +// Normally, the caching logic would be inside a framework. + +let cache = new Map(); + +export function fetchData(url) { + if (!cache.has(url)) { + cache.set(url, getData(url)); + } + return cache.get(url); +} + +async function getData(url) { + if (url.startsWith("/posts")) { + return await getPosts(); + } else { + throw Error("Not implemented"); + } +} + +async function getPosts() { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + let posts = []; + for (let i = 0; i < 500; i++) { + posts.push({ + id: i, + title: "Post #" + (i + 1), + }); + } + return posts; +} +``` + +```css +button { + margin-right: 10px; +} +b { + display: inline-block; + margin-right: 10px; +} +.pending { + color: #777; +} +``` + +[Read more about using transitions with Suspense.](/reference/react/Suspense#preventing-already-revealed-content-from-hiding) + +<Note> + +Transitions will only "wait" long enough to avoid hiding _already revealed_ content (like the tab container). If the Posts tab had a [nested `<Suspense>` boundary,](/reference/react/Suspense#revealing-nested-content-as-it-loads) the transition would not "wait" for it. + +</Note> + +--- + +### Building a Suspense-enabled router + +If you're building a React framework or a router, we recommend marking page navigations as transitions. + +```js +function Router() { + const [page, setPage] = useState('/'); + const [isPending, startTransition] = useTransition(); + + function navigate(url) { + startTransition(() => { + setPage(url); + }); + } + // ... +``` + +This is recommended for two reasons: + +- [Transitions are interruptible,](#marking-a-state-update-as-a-non-blocking-transition) which lets the user click away without waiting for the re-render to complete. +- [Transitions prevent unwanted loading indicators,](#preventing-unwanted-loading-indicators) which lets the user avoid jarring jumps on navigation. + +Here is a tiny simplified router example using transitions for navigations. + +```json package.json hidden +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```js +import { Suspense, useState, useTransition } from "react"; +import IndexPage from "./IndexPage.js"; +import ArtistPage from "./ArtistPage.js"; +import Layout from "./Layout.js"; + +export default function App() { + return ( + <Suspense fallback={<BigSpinner />}> + <Router /> + </Suspense> + ); +} + +function Router() { + const [page, setPage] = useState("/"); + const [isPending, startTransition] = useTransition(); + + function navigate(url) { + startTransition(() => { + setPage(url); + }); + } + + let content; + if (page === "/") { + content = <IndexPage navigate={navigate} />; + } else if (page === "/the-beatles") { + content = ( + <ArtistPage + artist={{ + id: "the-beatles", + name: "The Beatles", + }} + /> + ); + } + return <Layout isPending={isPending}>{content}</Layout>; +} + +function BigSpinner() { + return <h2>🌀 Loading...</h2>; +} +``` + +```js +export default function Layout({ children, isPending }) { + return ( + <div className="layout"> + <section + className="header" + style={{ + opacity: isPending ? 0.7 : 1, + }} + > + Music Browser + </section> + <main>{children}</main> + </div> + ); +} +``` + +```js +export default function IndexPage({ navigate }) { + return ( + <button onClick={() => navigate("/the-beatles")}> + Open The Beatles artist page + </button> + ); +} +``` + +```js +import { Suspense } from "react"; +import Albums from "./Albums.js"; +import Biography from "./Biography.js"; +import Panel from "./Panel.js"; + +export default function ArtistPage({ artist }) { + return ( + <> + <h1>{artist.name}</h1> + <Biography artistId={artist.id} /> + <Suspense fallback={<AlbumsGlimmer />}> + <Panel> + <Albums artistId={artist.id} /> + </Panel> + </Suspense> + </> + ); +} + +function AlbumsGlimmer() { + return ( + <div className="glimmer-panel"> + <div className="glimmer-line" /> + <div className="glimmer-line" /> + <div className="glimmer-line" /> + </div> + ); +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +export default function Albums({ artistId }) { + const albums = use(fetchData(`/${artistId}/albums`)); + return ( + <ul> + {albums.map((album) => ( + <li key={album.id}> + {album.title} ({album.year}) + </li> + ))} + </ul> + ); +} + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +import { fetchData } from "./data.js"; + +// Note: this component is written using an experimental API +// that's not yet available in stable versions of React. + +// For a realistic example you can follow today, try a framework +// that's integrated with Suspense, like Relay or Next.js. + +export default function Biography({ artistId }) { + const bio = use(fetchData(`/${artistId}/bio`)); + return ( + <section> + <p className="bio">{bio}</p> + </section> + ); +} + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +function use(promise) { + if (promise.status === "fulfilled") { + return promise.value; + } else if (promise.status === "rejected") { + throw promise.reason; + } else if (promise.status === "pending") { + throw promise; + } else { + promise.status = "pending"; + promise.then( + (result) => { + promise.status = "fulfilled"; + promise.value = result; + }, + (reason) => { + promise.status = "rejected"; + promise.reason = reason; + } + ); + throw promise; + } +} +``` + +```js +export default function Panel({ children }) { + return <section className="panel">{children}</section>; +} +``` + +```js +// Note: the way you would do data fetching depends on +// the framework that you use together with Suspense. +// Normally, the caching logic would be inside a framework. + +let cache = new Map(); + +export function fetchData(url) { + if (!cache.has(url)) { + cache.set(url, getData(url)); + } + return cache.get(url); +} + +async function getData(url) { + if (url === "/the-beatles/albums") { + return await getAlbums(); + } else if (url === "/the-beatles/bio") { + return await getBio(); + } else { + throw Error("Not implemented"); + } +} + +async function getBio() { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + return `The Beatles were an English rock band, + formed in Liverpool in 1960, that comprised + John Lennon, Paul McCartney, George Harrison + and Ringo Starr.`; +} + +async function getAlbums() { + // Add a fake delay to make waiting noticeable. + await new Promise((resolve) => { + setTimeout(resolve, 3000); + }); + + return [ + { + id: 13, + title: "Let It Be", + year: 1970, + }, + { + id: 12, + title: "Abbey Road", + year: 1969, + }, + { + id: 11, + title: "Yellow Submarine", + year: 1969, + }, + { + id: 10, + title: "The Beatles", + year: 1968, + }, + { + id: 9, + title: "Magical Mystery Tour", + year: 1967, + }, + { + id: 8, + title: "Sgt. Pepper's Lonely Hearts Club Band", + year: 1967, + }, + { + id: 7, + title: "Revolver", + year: 1966, + }, + { + id: 6, + title: "Rubber Soul", + year: 1965, + }, + { + id: 5, + title: "Help!", + year: 1965, + }, + { + id: 4, + title: "Beatles For Sale", + year: 1964, + }, + { + id: 3, + title: "A Hard Day's Night", + year: 1964, + }, + { + id: 2, + title: "With The Beatles", + year: 1963, + }, + { + id: 1, + title: "Please Please Me", + year: 1963, + }, + ]; +} +``` + +```css +main { + min-height: 200px; + padding: 10px; +} + +.layout { + border: 1px solid black; +} + +.header { + background: #222; + padding: 10px; + text-align: center; + color: white; +} + +.bio { + font-style: italic; +} + +.panel { + border: 1px solid #aaa; + border-radius: 6px; + margin-top: 20px; + padding: 10px; +} + +.glimmer-panel { + border: 1px dashed #aaa; + background: linear-gradient( + 90deg, + rgba(221, 221, 221, 1) 0%, + rgba(255, 255, 255, 1) 100% + ); + border-radius: 6px; + margin-top: 20px; + padding: 10px; +} + +.glimmer-line { + display: block; + width: 60%; + height: 20px; + margin: 10px; + border-radius: 4px; + background: #f0f0f0; +} +``` + +<Note> + +[Suspense-enabled](/reference/react/Suspense) routers are expected to wrap the navigation updates into transitions by default. + +</Note> + +--- + +## Troubleshooting + +### Updating an input in a transition doesn't work + +You can't use a transition for a state variable that controls an input: + +```js +const [text, setText] = useState(""); +// ... +function handleChange(e) { + // ❌ Can't use transitions for controlled input state + startTransition(() => { + setText(e.target.value); + }); +} +// ... +return <input value={text} onChange={handleChange} />; +``` + +This is because transitions are non-blocking, but updating an input in response to the change event should happen synchronously. If you want to run a transition in response to typing, you have two options: + +1. You can declare two separate state variables: one for the input state (which always updates synchronously), and one that you will update in a transition. This lets you control the input using the synchronous state, and pass the transition state variable (which will "lag behind" the input) to the rest of your rendering logic. +2. Alternatively, you can have one state variable, and add [`useDeferredValue`](/reference/react/useDeferredValue) which will "lag behind" the real value. It will trigger non-blocking re-renders to "catch up" with the new value automatically. + +--- + +### React doesn't treat my state update as a transition + +When you wrap a state update in a transition, make sure that it happens _during_ the `startTransition` call: + +```js +startTransition(() => { + // ✅ Setting state *during* startTransition call + setPage("/about"); +}); +``` + +The function you pass to `startTransition` must be synchronous. + +You can't mark an update as a transition like this: + +```js +startTransition(() => { + // ❌ Setting state *after* startTransition call + setTimeout(() => { + setPage("/about"); + }, 1000); +}); +``` + +Instead, you could do this: + +```js +setTimeout(() => { + startTransition(() => { + // ✅ Setting state *during* startTransition call + setPage("/about"); + }); +}, 1000); +``` + +Similarly, you can't mark an update as a transition like this: + +```js +startTransition(async () => { + await someAsyncFunction(); + // ❌ Setting state *after* startTransition call + setPage("/about"); +}); +``` + +However, this works instead: + +```js +await someAsyncFunction(); +startTransition(() => { + // ✅ Setting state *during* startTransition call + setPage("/about"); +}); +``` + +--- + +### I want to call `useTransition` from outside a component + +You can't call `useTransition` outside a component because it's a Hook. In this case, use the standalone [`startTransition`](/reference/react/startTransition) method instead. It works the same way, but it doesn't provide the `isPending` indicator. + +--- + +### The function I pass to `startTransition` executes immediately + +If you run this code, it will print 1, 2, 3: + +```js +console.log(1); +startTransition(() => { + console.log(2); + setPage("/about"); +}); +console.log(3); +``` + +**It is expected to print 1, 2, 3.** The function you pass to `startTransition` does not get delayed. Unlike with the browser `setTimeout`, it does not run the callback later. React executes your function immediately, but any state updates scheduled _while it is running_ are marked as transitions. You can imagine that it works like this: + +```js +// A simplified version of how React works + +let isInsideTransition = false; + +function startTransition(scope) { + isInsideTransition = true; + scope(); + isInsideTransition = false; +} + +function setState() { + if (isInsideTransition) { + // ... schedule a transition state update ... + } else { + // ... schedule an urgent state update ... + } +} +``` --> diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/js/.eslintrc.json b/src/js/.eslintrc.json deleted file mode 100644 index 8536da62b..000000000 --- a/src/js/.eslintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "env": { - "browser": true, - "node": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended" - ], - "overrides": [], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": ["react", "@typescript-eslint"], - "rules": { - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-empty-function": "off", - "react/prop-types": "off" - }, - "settings": { - "react": { - "version": "detect" - } - } -} diff --git a/src/js/.gitignore b/src/js/.gitignore deleted file mode 100644 index fedd7ea26..000000000 --- a/src/js/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -tsconfig.tsbuildinfo -packages/**/package-lock.json -dist diff --git a/src/js/README.md b/src/js/README.md deleted file mode 100644 index e99df49c0..000000000 --- a/src/js/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# ReactPy Client - -An ES6 Javascript client for ReactPy diff --git a/src/js/app/.eslintrc.json b/src/js/app/.eslintrc.json deleted file mode 100644 index 442025c3d..000000000 --- a/src/js/app/.eslintrc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended" - ], - "overrides": [], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": ["react", "@typescript-eslint"], - "rules": {} -} diff --git a/src/js/app/index.html b/src/js/app/index.html deleted file mode 100644 index e94280368..000000000 --- a/src/js/app/index.html +++ /dev/null @@ -1,15 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <script type="module"> - import { app } from "./src/index"; - app(document.getElementById("app")); - </script> - <!-- we replace this with user-provided head elements --> - {__head__} - </head> - <body> - <div id="app"></div> - </body> -</html> diff --git a/src/js/app/package-lock.json b/src/js/app/package-lock.json deleted file mode 100644 index 9794c53d6..000000000 --- a/src/js/app/package-lock.json +++ /dev/null @@ -1,1189 +0,0 @@ -{ - "name": "ui", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "license": "MIT", - "dependencies": { - "@reactpy/client": "^0.2.0", - "preact": "^10.7.0" - }, - "devDependencies": { - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5", - "vite": "^3.2.7" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", - "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", - "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@reactpy/client": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.2.1.tgz", - "integrity": "sha512-9sgGH+pJ2BpLT+QSVe7FQLS2VQ9acHgPlO8X3qiTumGw43O0X82sm8pzya8H8dAew463SeGza/pZc0mpUBHmqA==", - "dependencies": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true - }, - "node_modules/@types/react": { - "version": "17.0.57", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.57.tgz", - "integrity": "sha512-e4msYpu5QDxzNrXDHunU/VPyv2M1XemGG/p7kfCjUiPtlLDCWLGQfgAMng6YyisWYxZ09mYdQlmMnyS0NfZdEg==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "17.0.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.19.tgz", - "integrity": "sha512-PiYG40pnQRdPHnlf7tZnp0aQ6q9tspYr72vD61saO6zFCybLfMqwUCN0va1/P+86DXn18ZWeW30Bk7xlC5eEAQ==", - "dev": true, - "dependencies": { - "@types/react": "^17" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "dev": true - }, - "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true - }, - "node_modules/esbuild": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", - "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.15.18", - "@esbuild/linux-loong64": "0.15.18", - "esbuild-android-64": "0.15.18", - "esbuild-android-arm64": "0.15.18", - "esbuild-darwin-64": "0.15.18", - "esbuild-darwin-arm64": "0.15.18", - "esbuild-freebsd-64": "0.15.18", - "esbuild-freebsd-arm64": "0.15.18", - "esbuild-linux-32": "0.15.18", - "esbuild-linux-64": "0.15.18", - "esbuild-linux-arm": "0.15.18", - "esbuild-linux-arm64": "0.15.18", - "esbuild-linux-mips64le": "0.15.18", - "esbuild-linux-ppc64le": "0.15.18", - "esbuild-linux-riscv64": "0.15.18", - "esbuild-linux-s390x": "0.15.18", - "esbuild-netbsd-64": "0.15.18", - "esbuild-openbsd-64": "0.15.18", - "esbuild-sunos-64": "0.15.18", - "esbuild-windows-32": "0.15.18", - "esbuild-windows-64": "0.15.18", - "esbuild-windows-arm64": "0.15.18" - } - }, - "node_modules/esbuild-android-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", - "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-android-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", - "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", - "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", - "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", - "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", - "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", - "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", - "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", - "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", - "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-mips64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", - "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-ppc64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", - "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-riscv64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", - "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-s390x": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", - "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-netbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", - "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-openbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", - "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-sunos-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", - "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", - "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", - "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", - "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/event-to-object": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", - "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", - "dependencies": { - "json-pointer": "^0.6.2" - } - }, - "node_modules/foreach": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", - "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "peer": true - }, - "node_modules/json-pointer": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", - "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", - "dependencies": { - "foreach": "^2.0.4" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - } - ], - "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.13.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz", - "integrity": "sha512-q44QFLhOhty2Bd0Y46fnYW0gD/cbVM9dUVtNTDKPcdXSMA7jfY+Jpd6rk3GB0lcQss0z5s/6CmVP0Z/hV+g6pw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dev": true, - "dependencies": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", - "dev": true, - "dependencies": { - "esbuild": "^0.15.9", - "postcss": "^8.4.18", - "resolve": "^1.22.1", - "rollup": "^2.79.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - } - }, - "dependencies": { - "@esbuild/android-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", - "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", - "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", - "dev": true, - "optional": true - }, - "@reactpy/client": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.2.1.tgz", - "integrity": "sha512-9sgGH+pJ2BpLT+QSVe7FQLS2VQ9acHgPlO8X3qiTumGw43O0X82sm8pzya8H8dAew463SeGza/pZc0mpUBHmqA==", - "requires": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - } - }, - "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true - }, - "@types/react": { - "version": "17.0.57", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.57.tgz", - "integrity": "sha512-e4msYpu5QDxzNrXDHunU/VPyv2M1XemGG/p7kfCjUiPtlLDCWLGQfgAMng6YyisWYxZ09mYdQlmMnyS0NfZdEg==", - "dev": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/react-dom": { - "version": "17.0.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.19.tgz", - "integrity": "sha512-PiYG40pnQRdPHnlf7tZnp0aQ6q9tspYr72vD61saO6zFCybLfMqwUCN0va1/P+86DXn18ZWeW30Bk7xlC5eEAQ==", - "dev": true, - "requires": { - "@types/react": "^17" - } - }, - "@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "dev": true - }, - "csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true - }, - "esbuild": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", - "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", - "dev": true, - "requires": { - "@esbuild/android-arm": "0.15.18", - "@esbuild/linux-loong64": "0.15.18", - "esbuild-android-64": "0.15.18", - "esbuild-android-arm64": "0.15.18", - "esbuild-darwin-64": "0.15.18", - "esbuild-darwin-arm64": "0.15.18", - "esbuild-freebsd-64": "0.15.18", - "esbuild-freebsd-arm64": "0.15.18", - "esbuild-linux-32": "0.15.18", - "esbuild-linux-64": "0.15.18", - "esbuild-linux-arm": "0.15.18", - "esbuild-linux-arm64": "0.15.18", - "esbuild-linux-mips64le": "0.15.18", - "esbuild-linux-ppc64le": "0.15.18", - "esbuild-linux-riscv64": "0.15.18", - "esbuild-linux-s390x": "0.15.18", - "esbuild-netbsd-64": "0.15.18", - "esbuild-openbsd-64": "0.15.18", - "esbuild-sunos-64": "0.15.18", - "esbuild-windows-32": "0.15.18", - "esbuild-windows-64": "0.15.18", - "esbuild-windows-arm64": "0.15.18" - } - }, - "esbuild-android-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", - "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", - "dev": true, - "optional": true - }, - "esbuild-android-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", - "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", - "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", - "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", - "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", - "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", - "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", - "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", - "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", - "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", - "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", - "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", - "dev": true, - "optional": true - }, - "esbuild-linux-riscv64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", - "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", - "dev": true, - "optional": true - }, - "esbuild-linux-s390x": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", - "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", - "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", - "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", - "dev": true, - "optional": true - }, - "esbuild-sunos-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", - "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", - "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", - "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", - "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", - "dev": true, - "optional": true - }, - "event-to-object": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", - "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", - "requires": { - "json-pointer": "^0.6.2" - } - }, - "foreach": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", - "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "peer": true - }, - "json-pointer": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", - "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", - "requires": { - "foreach": "^2.0.4" - } - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "peer": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", - "dev": true, - "requires": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "preact": { - "version": "10.13.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz", - "integrity": "sha512-q44QFLhOhty2Bd0Y46fnYW0gD/cbVM9dUVtNTDKPcdXSMA7jfY+Jpd6rk3GB0lcQss0z5s/6CmVP0Z/hV+g6pw==" - }, - "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - } - }, - "resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dev": true, - "requires": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true - }, - "vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", - "dev": true, - "requires": { - "esbuild": "^0.15.9", - "fsevents": "~2.3.2", - "postcss": "^8.4.18", - "resolve": "^1.22.1", - "rollup": "^2.79.1" - } - } - } -} diff --git a/src/js/app/package.json b/src/js/app/package.json deleted file mode 100644 index 40ce94739..000000000 --- a/src/js/app/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "author": "Ryan Morshead", - "license": "MIT", - "main": "src/dist/index.js", - "types": "src/dist/index.d.ts", - "description": "A client application for ReactPy implemented in React", - "dependencies": { - "@reactpy/client": "^0.2.0", - "preact": "^10.7.0" - }, - "devDependencies": { - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5", - "vite": "^3.2.7" - }, - "repository": { - "type": "git", - "url": "https://github.com/reactive-python/reactpy" - }, - "scripts": { - "build": "vite build", - "format": "prettier --write . && eslint --fix .", - "test": "npm run check:tests", - "check:tests": "echo 'no tests'", - "check:types": "tsc --noEmit" - } -} diff --git a/src/js/app/public/assets/reactpy-logo.ico b/src/js/app/public/assets/reactpy-logo.ico deleted file mode 100644 index 62be5f5ba..000000000 Binary files a/src/js/app/public/assets/reactpy-logo.ico and /dev/null differ diff --git a/src/js/app/src/index.ts b/src/js/app/src/index.ts deleted file mode 100644 index 1f47853aa..000000000 --- a/src/js/app/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mount, SimpleReactPyClient } from "@reactpy/client"; - -export function app(element: HTMLElement) { - const client = new SimpleReactPyClient({ - serverLocation: { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - }); - mount(element, client); -} diff --git a/src/js/app/tsconfig.json b/src/js/app/tsconfig.json deleted file mode 100644 index c736ab13d..000000000 --- a/src/js/app/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../tsconfig.package.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "composite": true - }, - "include": ["src"], - "references": [ - { - "path": "../packages/@reactpy/client" - } - ] -} diff --git a/src/js/app/vite.config.js b/src/js/app/vite.config.js deleted file mode 100644 index c97fb6dac..000000000 --- a/src/js/app/vite.config.js +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - build: { emptyOutDir: true }, - resolve: { - alias: { - react: "preact/compat", - "react-dom": "preact/compat", - }, - }, - base: "/_reactpy/", -}); diff --git a/src/js/package-lock.json b/src/js/package-lock.json deleted file mode 100644 index 2edfdd260..000000000 --- a/src/js/package-lock.json +++ /dev/null @@ -1,6003 +0,0 @@ -{ - "name": "js", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "license": "MIT", - "workspaces": [ - "packages/event-to-object", - "packages/@reactpy/client", - "app" - ], - "devDependencies": { - "@typescript-eslint/eslint-plugin": "^5.58.0", - "@typescript-eslint/parser": "^5.58.0", - "eslint": "^8.38.0", - "eslint-plugin-react": "^7.32.2", - "prettier": "^3.0.0-alpha.6" - } - }, - "app": { - "license": "MIT", - "dependencies": { - "@reactpy/client": "^0.2.0", - "preact": "^10.7.0" - }, - "devDependencies": { - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5", - "vite": "^3.1.8" - } - }, - "app/node_modules/@reactpy/client": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.2.1.tgz", - "integrity": "sha512-9sgGH+pJ2BpLT+QSVe7FQLS2VQ9acHgPlO8X3qiTumGw43O0X82sm8pzya8H8dAew463SeGza/pZc0mpUBHmqA==", - "dependencies": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "app/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "apps/ui": { - "extraneous": true, - "license": "MIT", - "dependencies": { - "@reactpy/client": "^0.2.0", - "preact": "^10.7.0" - }, - "devDependencies": { - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "prettier": "^3.0.0-alpha.6", - "typescript": "^4.9.5", - "vite": "^3.1.8" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", - "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", - "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", - "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "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==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.5.1", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", - "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@reactpy/client": { - "resolved": "packages/@reactpy/client", - "link": true - }, - "node_modules/@types/json-pointer": { - "version": "1.0.31", - "resolved": "https://registry.npmjs.org/@types/json-pointer/-/json-pointer-1.0.31.tgz", - "integrity": "sha512-hTPul7Um6LqsHXHQpdkXTU7Oysjsf+9k4Yfmg6JhSKG/jj9QuQGyMUdj6trPH6WHiIdxw7nYSROgOxeFmCVK2w==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", - "dev": true - }, - "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true - }, - "node_modules/@types/react": { - "version": "17.0.53", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.53.tgz", - "integrity": "sha512-1yIpQR2zdYu1Z/dc1OxC+MA6GR240u3gcnP4l6mvj/PJiVaqHsQPmWttsvHsfnhfPbU2FuGmo0wSITPygjBmsw==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "17.0.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.19.tgz", - "integrity": "sha512-PiYG40pnQRdPHnlf7tZnp0aQ6q9tspYr72vD61saO6zFCybLfMqwUCN0va1/P+86DXn18ZWeW30Bk7xlC5eEAQ==", - "dev": true, - "dependencies": { - "@types/react": "^17" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.3.13", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", - "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", - "dev": true - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.58.0.tgz", - "integrity": "sha512-vxHvLhH0qgBd3/tW6/VccptSfc8FxPQIkmNTVLWcCOVqSBvqpnKkBTYrhcGlXfSnd78azwe+PsjYFj0X34/njA==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.58.0", - "@typescript-eslint/type-utils": "5.58.0", - "@typescript-eslint/utils": "5.58.0", - "debug": "^4.3.4", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.58.0.tgz", - "integrity": "sha512-ixaM3gRtlfrKzP8N6lRhBbjTow1t6ztfBvQNGuRM8qH1bjFFXIJ35XY+FC0RRBKn3C6cT+7VW1y8tNm7DwPHDQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "5.58.0", - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/typescript-estree": "5.58.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.58.0.tgz", - "integrity": "sha512-b+w8ypN5CFvrXWQb9Ow9T4/6LC2MikNf1viLkYTiTbkQl46CnR69w7lajz1icW0TBsYmlpg+mRzFJ4LEJ8X9NA==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/visitor-keys": "5.58.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.58.0.tgz", - "integrity": "sha512-FF5vP/SKAFJ+LmR9PENql7fQVVgGDOS+dq3j+cKl9iW/9VuZC/8CFmzIP0DLKXfWKpRHawJiG70rVH+xZZbp8w==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "5.58.0", - "@typescript-eslint/utils": "5.58.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.58.0.tgz", - "integrity": "sha512-JYV4eITHPzVQMnHZcYJXl2ZloC7thuUHrcUmxtzvItyKPvQ50kb9QXBkgNAt90OYMqwaodQh2kHutWZl1fc+1g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.58.0.tgz", - "integrity": "sha512-cRACvGTodA+UxnYM2uwA2KCwRL7VAzo45syNysqlMyNyjw0Z35Icc9ihPJZjIYuA5bXJYiJ2YGUB59BqlOZT1Q==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/visitor-keys": "5.58.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.58.0.tgz", - "integrity": "sha512-gAmLOTFXMXOC+zP1fsqm3VceKSBQJNzV385Ok3+yzlavNHZoedajjS4UyS21gabJYcobuigQPs/z71A9MdJFqQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.58.0", - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/typescript-estree": "5.58.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.58.0.tgz", - "integrity": "sha512-/fBraTlPj0jwdyTwLyrRTxv/3lnU2H96pNTVM6z3esTWLtA5MZ9ghSMJ7Rb+TtUAdtEw9EyJzJ0EydIMKxQ9gA==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.58.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/app": { - "resolved": "app", - "link": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true - }, - "node_modules/csstype": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", - "dev": true, - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/es-abstract": { - "version": "1.21.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", - "integrity": "sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", - "get-symbol-description": "^1.0.0", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-length": "^1.0.4", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", - "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.15.18", - "@esbuild/linux-loong64": "0.15.18", - "esbuild-android-64": "0.15.18", - "esbuild-android-arm64": "0.15.18", - "esbuild-darwin-64": "0.15.18", - "esbuild-darwin-arm64": "0.15.18", - "esbuild-freebsd-64": "0.15.18", - "esbuild-freebsd-arm64": "0.15.18", - "esbuild-linux-32": "0.15.18", - "esbuild-linux-64": "0.15.18", - "esbuild-linux-arm": "0.15.18", - "esbuild-linux-arm64": "0.15.18", - "esbuild-linux-mips64le": "0.15.18", - "esbuild-linux-ppc64le": "0.15.18", - "esbuild-linux-riscv64": "0.15.18", - "esbuild-linux-s390x": "0.15.18", - "esbuild-netbsd-64": "0.15.18", - "esbuild-openbsd-64": "0.15.18", - "esbuild-sunos-64": "0.15.18", - "esbuild-windows-32": "0.15.18", - "esbuild-windows-64": "0.15.18", - "esbuild-windows-arm64": "0.15.18" - } - }, - "node_modules/esbuild-android-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", - "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-android-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", - "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", - "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", - "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", - "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", - "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", - "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", - "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", - "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", - "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-mips64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", - "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-ppc64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", - "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-riscv64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", - "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-s390x": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", - "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-netbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", - "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-openbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", - "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-sunos-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", - "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", - "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", - "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", - "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", - "integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.38.0", - "@humanwhocodes/config-array": "^0.11.8", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.32.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", - "integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "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==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", - "dev": true, - "dependencies": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-to-object": { - "resolved": "packages/event-to-object", - "link": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/foreach": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", - "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, - "node_modules/happy-dom": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-8.9.0.tgz", - "integrity": "sha512-JZwJuGdR7ko8L61136YzmrLv7LgTh5b8XaEM3P709mLjyQuXJ3zHTDXvUtBBahRjGlcYW0zGjIiEWizoTUGKfA==", - "dev": true, - "dependencies": { - "css.escape": "^1.5.1", - "he": "^1.2.0", - "iconv-lite": "^0.6.3", - "node-fetch": "^2.x.x", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "bin": { - "he": "bin/he" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/js-sdsl": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", - "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-pointer": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", - "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", - "dependencies": { - "foreach": "^2.0.4" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true - }, - "node_modules/node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.15.1.tgz", - "integrity": "sha512-qs2ansoQEwzNiV5eAcRT1p1EC/dmEzaATVDJNiB3g2sRDWdA7b7MurXdJjB2+/WQktGWZwxvDrnuRFbWuIr64g==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0-alpha.6.tgz", - "integrity": "sha512-AdbQSZ6Oo+iy9Ekzmsgno05P1uX2vqPkjOMJqRfP8hTe+m6iDw4Nt7bPFpWZ/HYCU+3f0P5U0o2ghxQwwkLH7A==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dev": true, - "dependencies": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "node_modules/semver": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", - "integrity": "sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "node_modules/tsm": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tsm/-/tsm-2.3.0.tgz", - "integrity": "sha512-++0HFnmmR+gMpDtKTnW3XJ4yv9kVGi20n+NfyQWB9qwJvTaIWY9kBmzek2YUQK5APTQ/1DTrXmm4QtFPmW9Rzw==", - "dev": true, - "dependencies": { - "esbuild": "^0.15.16" - }, - "bin": { - "tsm": "bin.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=12.20" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "dev": true, - "dependencies": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - }, - "bin": { - "uvu": "bin.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", - "dev": true, - "dependencies": { - "esbuild": "^0.15.9", - "postcss": "^8.4.18", - "resolve": "^1.22.1", - "rollup": "^2.79.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/@reactpy/client": { - "version": "0.3.1", - "license": "MIT", - "dependencies": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "packages/@reactpy/client/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "packages/app": { - "name": "@reactpy/app", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@reactpy/client": "^0.1.0", - "preact": "^10.7.0" - }, - "devDependencies": { - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "prettier": "^3.0.0-alpha.6", - "typescript": "^4.9.5", - "vite": "^3.1.8" - } - }, - "packages/client": { - "name": "@reactpy/client", - "version": "0.2.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "event-to-object": "^0.1.0", - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "prettier": "^3.0.0-alpha.6", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - } - }, - "packages/event-to-object": { - "version": "0.1.2", - "license": "MIT", - "dependencies": { - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "happy-dom": "^8.9.0", - "lodash": "^4.17.21", - "tsm": "^2.0.0", - "typescript": "^4.9.5", - "uvu": "^0.5.1" - } - }, - "packages/event-to-object/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "packages/event-to-object/packages/event-to-object": { - "extraneous": true - }, - "ui": { - "extraneous": true, - "license": "MIT", - "dependencies": { - "@reactpy/client": "^0.2.0", - "preact": "^10.7.0" - }, - "devDependencies": { - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5", - "vite": "^3.1.8" - } - } - }, - "dependencies": { - "@esbuild/android-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", - "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", - "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", - "dev": true, - "optional": true - }, - "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.3.0" - } - }, - "@eslint-community/regexpp": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", - "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", - "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==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.5.1", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - } - }, - "@eslint/js": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", - "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", - "dev": true - }, - "@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@reactpy/client": { - "version": "file:packages/@reactpy/client", - "requires": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2", - "typescript": "^4.9.5" - }, - "dependencies": { - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true - } - } - }, - "@types/json-pointer": { - "version": "1.0.31", - "resolved": "https://registry.npmjs.org/@types/json-pointer/-/json-pointer-1.0.31.tgz", - "integrity": "sha512-hTPul7Um6LqsHXHQpdkXTU7Oysjsf+9k4Yfmg6JhSKG/jj9QuQGyMUdj6trPH6WHiIdxw7nYSROgOxeFmCVK2w==", - "dev": true - }, - "@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", - "dev": true - }, - "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true - }, - "@types/react": { - "version": "17.0.53", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.53.tgz", - "integrity": "sha512-1yIpQR2zdYu1Z/dc1OxC+MA6GR240u3gcnP4l6mvj/PJiVaqHsQPmWttsvHsfnhfPbU2FuGmo0wSITPygjBmsw==", - "dev": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/react-dom": { - "version": "17.0.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.19.tgz", - "integrity": "sha512-PiYG40pnQRdPHnlf7tZnp0aQ6q9tspYr72vD61saO6zFCybLfMqwUCN0va1/P+86DXn18ZWeW30Bk7xlC5eEAQ==", - "dev": true, - "requires": { - "@types/react": "^17" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true - }, - "@types/semver": { - "version": "7.3.13", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", - "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", - "dev": true - }, - "@typescript-eslint/eslint-plugin": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.58.0.tgz", - "integrity": "sha512-vxHvLhH0qgBd3/tW6/VccptSfc8FxPQIkmNTVLWcCOVqSBvqpnKkBTYrhcGlXfSnd78azwe+PsjYFj0X34/njA==", - "dev": true, - "requires": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.58.0", - "@typescript-eslint/type-utils": "5.58.0", - "@typescript-eslint/utils": "5.58.0", - "debug": "^4.3.4", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - } - }, - "@typescript-eslint/parser": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.58.0.tgz", - "integrity": "sha512-ixaM3gRtlfrKzP8N6lRhBbjTow1t6ztfBvQNGuRM8qH1bjFFXIJ35XY+FC0RRBKn3C6cT+7VW1y8tNm7DwPHDQ==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "5.58.0", - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/typescript-estree": "5.58.0", - "debug": "^4.3.4" - } - }, - "@typescript-eslint/scope-manager": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.58.0.tgz", - "integrity": "sha512-b+w8ypN5CFvrXWQb9Ow9T4/6LC2MikNf1viLkYTiTbkQl46CnR69w7lajz1icW0TBsYmlpg+mRzFJ4LEJ8X9NA==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/visitor-keys": "5.58.0" - } - }, - "@typescript-eslint/type-utils": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.58.0.tgz", - "integrity": "sha512-FF5vP/SKAFJ+LmR9PENql7fQVVgGDOS+dq3j+cKl9iW/9VuZC/8CFmzIP0DLKXfWKpRHawJiG70rVH+xZZbp8w==", - "dev": true, - "requires": { - "@typescript-eslint/typescript-estree": "5.58.0", - "@typescript-eslint/utils": "5.58.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - } - }, - "@typescript-eslint/types": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.58.0.tgz", - "integrity": "sha512-JYV4eITHPzVQMnHZcYJXl2ZloC7thuUHrcUmxtzvItyKPvQ50kb9QXBkgNAt90OYMqwaodQh2kHutWZl1fc+1g==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.58.0.tgz", - "integrity": "sha512-cRACvGTodA+UxnYM2uwA2KCwRL7VAzo45syNysqlMyNyjw0Z35Icc9ihPJZjIYuA5bXJYiJ2YGUB59BqlOZT1Q==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/visitor-keys": "5.58.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - } - }, - "@typescript-eslint/utils": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.58.0.tgz", - "integrity": "sha512-gAmLOTFXMXOC+zP1fsqm3VceKSBQJNzV385Ok3+yzlavNHZoedajjS4UyS21gabJYcobuigQPs/z71A9MdJFqQ==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.58.0", - "@typescript-eslint/types": "5.58.0", - "@typescript-eslint/typescript-estree": "5.58.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, - "dependencies": { - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - } - } - }, - "@typescript-eslint/visitor-keys": { - "version": "5.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.58.0.tgz", - "integrity": "sha512-/fBraTlPj0jwdyTwLyrRTxv/3lnU2H96pNTVM6z3esTWLtA5MZ9ghSMJ7Rb+TtUAdtEw9EyJzJ0EydIMKxQ9gA==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.58.0", - "eslint-visitor-keys": "^3.3.0" - } - }, - "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "app": { - "version": "file:app", - "requires": { - "@reactpy/client": "^0.2.0", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "preact": "^10.7.0", - "typescript": "^4.9.5", - "vite": "^3.1.8" - }, - "dependencies": { - "@reactpy/client": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.2.1.tgz", - "integrity": "sha512-9sgGH+pJ2BpLT+QSVe7FQLS2VQ9acHgPlO8X3qiTumGw43O0X82sm8pzya8H8dAew463SeGza/pZc0mpUBHmqA==", - "requires": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - } - }, - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true - } - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - } - }, - "array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - } - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true - }, - "csstype": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "dev": true - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", - "dev": true, - "requires": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - } - }, - "dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true - }, - "diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "es-abstract": { - "version": "1.21.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", - "integrity": "sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==", - "dev": true, - "requires": { - "array-buffer-byte-length": "^1.0.0", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", - "get-symbol-description": "^1.0.0", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-length": "^1.0.4", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" - } - }, - "es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" - } - }, - "es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "esbuild": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", - "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", - "dev": true, - "requires": { - "@esbuild/android-arm": "0.15.18", - "@esbuild/linux-loong64": "0.15.18", - "esbuild-android-64": "0.15.18", - "esbuild-android-arm64": "0.15.18", - "esbuild-darwin-64": "0.15.18", - "esbuild-darwin-arm64": "0.15.18", - "esbuild-freebsd-64": "0.15.18", - "esbuild-freebsd-arm64": "0.15.18", - "esbuild-linux-32": "0.15.18", - "esbuild-linux-64": "0.15.18", - "esbuild-linux-arm": "0.15.18", - "esbuild-linux-arm64": "0.15.18", - "esbuild-linux-mips64le": "0.15.18", - "esbuild-linux-ppc64le": "0.15.18", - "esbuild-linux-riscv64": "0.15.18", - "esbuild-linux-s390x": "0.15.18", - "esbuild-netbsd-64": "0.15.18", - "esbuild-openbsd-64": "0.15.18", - "esbuild-sunos-64": "0.15.18", - "esbuild-windows-32": "0.15.18", - "esbuild-windows-64": "0.15.18", - "esbuild-windows-arm64": "0.15.18" - } - }, - "esbuild-android-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", - "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", - "dev": true, - "optional": true - }, - "esbuild-android-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", - "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", - "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", - "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", - "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", - "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", - "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", - "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", - "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", - "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", - "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", - "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", - "dev": true, - "optional": true - }, - "esbuild-linux-riscv64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", - "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", - "dev": true, - "optional": true - }, - "esbuild-linux-s390x": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", - "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", - "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", - "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", - "dev": true, - "optional": true - }, - "esbuild-sunos-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", - "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", - "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", - "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.15.18", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", - "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", - "dev": true, - "optional": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", - "integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.38.0", - "@humanwhocodes/config-array": "^0.11.8", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - } - }, - "eslint-plugin-react": { - "version": "7.32.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", - "integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==", - "dev": true, - "requires": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" - }, - "dependencies": { - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "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==", - "dev": true - }, - "espree": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", - "dev": true, - "requires": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" - } - }, - "esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "event-to-object": { - "version": "file:packages/event-to-object", - "requires": { - "happy-dom": "^8.9.0", - "json-pointer": "^0.6.2", - "lodash": "^4.17.21", - "tsm": "^2.0.0", - "typescript": "^4.9.5", - "uvu": "^0.5.1" - }, - "dependencies": { - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true - } - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true - }, - "for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "requires": { - "is-callable": "^1.1.3" - } - }, - "foreach": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", - "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - } - }, - "functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true - }, - "get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - } - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dev": true, - "requires": { - "define-properties": "^1.1.3" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } - }, - "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.3" - } - }, - "grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, - "happy-dom": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-8.9.0.tgz", - "integrity": "sha512-JZwJuGdR7ko8L61136YzmrLv7LgTh5b8XaEM3P709mLjyQuXJ3zHTDXvUtBBahRjGlcYW0zGjIiEWizoTUGKfA==", - "dev": true, - "requires": { - "css.escape": "^1.5.1", - "he": "^1.2.0", - "iconv-lite": "^0.6.3", - "node-fetch": "^2.x.x", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.1" - } - }, - "has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "dev": true, - "requires": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - } - }, - "is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - } - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "requires": { - "has-bigints": "^1.0.1" - } - }, - "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true - }, - "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dev": true, - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - } - }, - "is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "js-sdsl": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", - "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-pointer": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", - "integrity": "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==", - "requires": { - "foreach": "^2.0.4" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "requires": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - } - }, - "kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "requires": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true - }, - "node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - } - }, - "object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, - "requires": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", - "dev": true, - "requires": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "preact": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.15.1.tgz", - "integrity": "sha512-qs2ansoQEwzNiV5eAcRT1p1EC/dmEzaATVDJNiB3g2sRDWdA7b7MurXdJjB2+/WQktGWZwxvDrnuRFbWuIr64g==" - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prettier": { - "version": "3.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0-alpha.6.tgz", - "integrity": "sha512-AdbQSZ6Oo+iy9Ekzmsgno05P1uX2vqPkjOMJqRfP8hTe+m6iDw4Nt7bPFpWZ/HYCU+3f0P5U0o2ghxQwwkLH7A==", - "dev": true - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - } - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - } - }, - "resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dev": true, - "requires": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "requires": { - "mri": "^1.1.0" - } - }, - "safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "semver": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", - "integrity": "sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true - }, - "string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" - } - }, - "string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "tsm": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tsm/-/tsm-2.3.0.tgz", - "integrity": "sha512-++0HFnmmR+gMpDtKTnW3XJ4yv9kVGi20n+NfyQWB9qwJvTaIWY9kBmzek2YUQK5APTQ/1DTrXmm4QtFPmW9Rzw==", - "dev": true, - "requires": { - "esbuild": "^0.15.16" - } - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "requires": { - "tslib": "^1.8.1" - } - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" - } - }, - "typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true, - "peer": true - }, - "unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "dev": true, - "requires": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - } - }, - "vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", - "dev": true, - "requires": { - "esbuild": "^0.15.9", - "fsevents": "~2.3.2", - "postcss": "^8.4.18", - "resolve": "^1.22.1", - "rollup": "^2.79.1" - } - }, - "webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true - }, - "whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, - "requires": { - "iconv-lite": "0.6.3" - } - }, - "whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - }, - "dependencies": { - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true - } - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - } - }, - "which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "dev": true, - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" - } - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - } - } -} diff --git a/src/js/package.json b/src/js/package.json deleted file mode 100644 index a9d84814b..000000000 --- a/src/js/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "license": "MIT", - "scripts": { - "publish": "npm --workspaces publish", - "test": "npm --workspaces test", - "build": "npm --workspaces run build", - "fix:format": "npm run prettier -- --write && npm run eslint -- --fix", - "check:format": "npm run prettier -- --check && npm run eslint", - "check:tests": "npm --workspaces run check:tests", - "check:types": "npm --workspaces run check:types", - "prettier": "prettier --ignore-path .gitignore .", - "eslint": "eslint --ignore-path .gitignore ." - }, - "workspaces": [ - "packages/event-to-object", - "packages/@reactpy/client", - "app" - ], - "devDependencies": { - "@typescript-eslint/eslint-plugin": "^5.58.0", - "@typescript-eslint/parser": "^5.58.0", - "eslint": "^8.38.0", - "eslint-plugin-react": "^7.32.2", - "prettier": "^3.0.0-alpha.6" - } -} diff --git a/src/js/packages/@reactpy/client/.gitignore b/src/js/packages/@reactpy/client/.gitignore deleted file mode 100644 index 787df98f6..000000000 --- a/src/js/packages/@reactpy/client/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Javascript -# ---------- -node_modules - -# IDE -# --- -.vscode -.idea diff --git a/src/js/packages/@reactpy/client/README.md b/src/js/packages/@reactpy/client/README.md deleted file mode 100644 index a01929943..000000000 --- a/src/js/packages/@reactpy/client/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @reactpy/client - -A client for ReactPy implemented in React diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json deleted file mode 100644 index ab4bd34ad..000000000 --- a/src/js/packages/@reactpy/client/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "author": "Ryan Morshead", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "description": "A client for ReactPy implemented in React", - "license": "MIT", - "name": "@reactpy/client", - "type": "module", - "version": "0.3.1", - "dependencies": { - "event-to-object": "^0.1.2", - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "@types/json-pointer": "^1.0.31", - "@types/react": "^17.0", - "@types/react-dom": "^17.0", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "react": ">=16 <18", - "react-dom": ">=16 <18" - }, - "repository": { - "type": "git", - "url": "https://github.com/reactive-python/reactpy" - }, - "scripts": { - "build": "tsc -b", - "test": "npm run check:tests", - "check:tests": "echo 'no tests'", - "check:types": "tsc --noEmit" - } -} diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx deleted file mode 100644 index 728c4cec7..000000000 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { - createElement, - createContext, - useState, - useRef, - useContext, - useEffect, - Fragment, - MutableRefObject, - ChangeEvent, -} from "react"; -// @ts-ignore -import { set as setJsonPointer } from "json-pointer"; -import { - ReactPyVdom, - ReactPyComponent, - createChildren, - createAttributes, - loadImportSource, - ImportSourceBinding, -} from "./reactpy-vdom"; -import { ReactPyClient } from "./reactpy-client"; - -const ClientContext = createContext<ReactPyClient>(null as any); - -export function Layout(props: { client: ReactPyClient }): JSX.Element { - const currentModel: ReactPyVdom = useState({ tagName: "" })[0]; - const forceUpdate = useForceUpdate(); - - useEffect( - () => - props.client.onMessage("layout-update", ({ path, model }) => { - if (path === "") { - Object.assign(currentModel, model); - } else { - setJsonPointer(currentModel, path, model); - } - forceUpdate(); - }), - [currentModel, props.client], - ); - - return ( - <ClientContext.Provider value={props.client}> - <Element model={currentModel} /> - </ClientContext.Provider> - ); -} - -export function Element({ model }: { model: ReactPyVdom }): JSX.Element | null { - if (model.error !== undefined) { - if (model.error) { - return <pre>{model.error}</pre>; - } else { - return null; - } - } - - let SpecializedElement: ReactPyComponent; - if (model.tagName in SPECIAL_ELEMENTS) { - SpecializedElement = - SPECIAL_ELEMENTS[model.tagName as keyof typeof SPECIAL_ELEMENTS]; - } else if (model.importSource) { - SpecializedElement = ImportedElement; - } else { - SpecializedElement = StandardElement; - } - - return <SpecializedElement model={model} />; -} - -function StandardElement({ model }: { model: ReactPyVdom }) { - const client = React.useContext(ClientContext); - // Use createElement here to avoid warning about variable numbers of children not - // having keys. Warning about this must now be the responsibility of the client - // providing the models instead of the client rendering them. - return createElement( - model.tagName === "" ? Fragment : model.tagName, - createAttributes(model, client), - ...createChildren(model, (child) => { - return <Element model={child} key={child.key} />; - }), - ); -} - -function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element { - const client = useContext(ClientContext); - const props = createAttributes(model, client); - const [value, setValue] = React.useState(props.value); - - // honor changes to value from the client via props - React.useEffect(() => setValue(props.value), [props.value]); - - const givenOnChange = props.onChange; - if (typeof givenOnChange === "function") { - props.onChange = (event: ChangeEvent<any>) => { - // immediately update the value to give the user feedback - setValue(event.target.value); - // allow the client to respond (and possibly change the value) - givenOnChange(event); - }; - } - - // Use createElement here to avoid warning about variable numbers of children not - // having keys. Warning about this must now be the responsibility of the client - // providing the models instead of the client rendering them. - return createElement( - model.tagName, - // overwrite - { ...props, value }, - ...createChildren(model, (child) => ( - <Element model={child} key={child.key} /> - )), - ); -} - -function ScriptElement({ model }: { model: ReactPyVdom }) { - const ref = useRef<HTMLDivElement | null>(null); - - React.useEffect(() => { - if (!ref.current) { - return; - } - const scriptContent = model?.children?.filter( - (value): value is string => typeof value == "string", - )[0]; - - let scriptElement: HTMLScriptElement; - if (model.attributes) { - scriptElement = document.createElement("script"); - for (const [k, v] of Object.entries(model.attributes)) { - scriptElement.setAttribute(k, v); - } - if (scriptContent) { - scriptElement.appendChild(document.createTextNode(scriptContent)); - } - ref.current.appendChild(scriptElement); - } else if (scriptContent) { - const scriptResult = eval(scriptContent); - if (typeof scriptResult == "function") { - return scriptResult(); - } - } - }, [model.key, ref.current]); - - return <div ref={ref} />; -} - -function ImportedElement({ model }: { model: ReactPyVdom }) { - const importSourceVdom = model.importSource; - const importSourceRef = useImportSource(model); - - if (!importSourceVdom) { - return null; - } - - const importSourceFallback = importSourceVdom.fallback; - - if (!importSourceVdom) { - // display a fallback if one was given - if (!importSourceFallback) { - return null; - } else if (typeof importSourceFallback === "string") { - return <span>{importSourceFallback}</span>; - } else { - return <StandardElement model={importSourceFallback} />; - } - } else { - return <span ref={importSourceRef} />; - } -} - -function useForceUpdate() { - const [, setState] = useState(false); - return () => setState((old) => !old); -} - -function useImportSource(model: ReactPyVdom): MutableRefObject<any> { - const vdomImportSource = model.importSource; - - const mountPoint = useRef<HTMLElement>(null); - const client = React.useContext(ClientContext); - const [binding, setBinding] = useState<ImportSourceBinding | null>(null); - - React.useEffect(() => { - let unmounted = false; - - if (vdomImportSource) { - loadImportSource(vdomImportSource, client).then((bind) => { - if (!unmounted && mountPoint.current) { - setBinding(bind(mountPoint.current)); - } - }); - } - - return () => { - unmounted = true; - if ( - binding && - vdomImportSource && - !vdomImportSource.unmountBeforeUpdate - ) { - binding.unmount(); - } - }; - }, [client, vdomImportSource, setBinding, mountPoint.current]); - - // this effect must run every time in case the model has changed - useEffect(() => { - if (!(binding && vdomImportSource)) { - return; - } - binding.render(model); - if (vdomImportSource.unmountBeforeUpdate) { - return binding.unmount; - } - }); - - return mountPoint; -} - -const SPECIAL_ELEMENTS = { - input: UserInputElement, - script: ScriptElement, - select: UserInputElement, - textarea: UserInputElement, -}; diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts deleted file mode 100644 index 548fcbfc7..000000000 --- a/src/js/packages/@reactpy/client/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./components"; -export * from "./messages"; -export * from "./mount"; -export * from "./reactpy-client"; -export * from "./reactpy-vdom"; diff --git a/src/js/packages/@reactpy/client/src/logger.ts b/src/js/packages/@reactpy/client/src/logger.ts deleted file mode 100644 index 4c4cdd264..000000000 --- a/src/js/packages/@reactpy/client/src/logger.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default { - log: (...args: any[]): void => console.log("[ReactPy]", ...args), - warn: (...args: any[]): void => console.warn("[ReactPy]", ...args), - error: (...args: any[]): void => console.error("[ReactPy]", ...args), -}; diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts deleted file mode 100644 index 34001dcb0..000000000 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReactPyVdom } from "./reactpy-vdom"; - -export type LayoutUpdateMessage = { - type: "layout-update"; - path: string; - model: ReactPyVdom; -}; - -export type LayoutEventMessage = { - type: "layout-event"; - target: string; - data: any; -}; - -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; -export type Message = IncomingMessage | OutgoingMessage; diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx deleted file mode 100644 index 0b824a4ee..000000000 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; -import { render } from "react-dom"; -import { Layout } from "./components"; -import { ReactPyClient } from "./reactpy-client"; - -export function mount(element: HTMLElement, client: ReactPyClient): void { - render(<Layout client={client} />, element); -} diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts deleted file mode 100644 index 6f37b55a1..000000000 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { ReactPyModule } from "./reactpy-vdom"; -import logger from "./logger"; - -/** - * A client for communicating with a ReactPy server. - */ -export interface ReactPyClient { - /** - * Register a handler for a message type. - * - * The first time this is called, the client will be considered ready. - * - * @param type The type of message to handle. - * @param handler The handler to call when a message of the given type is received. - * @returns A function to unregister the handler. - */ - onMessage(type: string, handler: (message: any) => void): () => void; - - /** - * Send a message to the server. - * - * @param message The message to send. Messages must have a `type` property. - */ - sendMessage(message: any): void; - - /** - * Load a module from the server. - * @param moduleName The name of the module to load. - * @returns A promise that resolves to the module. - */ - loadModule(moduleName: string): Promise<ReactPyModule>; -} - -export abstract class BaseReactPyClient implements ReactPyClient { - private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; - protected readonly ready: Promise<void>; - private resolveReady: (value: undefined) => void; - - constructor() { - this.resolveReady = () => {}; - this.ready = new Promise((resolve) => (this.resolveReady = resolve)); - } - - onMessage(type: string, handler: (message: any) => void): () => void { - (this.handlers[type] || (this.handlers[type] = [])).push(handler); - this.resolveReady(undefined); - return () => { - this.handlers[type] = this.handlers[type].filter((h) => h !== handler); - }; - } - - abstract sendMessage(message: any): void; - abstract loadModule(moduleName: string): Promise<ReactPyModule>; - - /** - * Handle an incoming message. - * - * This should be called by subclasses when a message is received. - * - * @param message The message to handle. The message must have a `type` property. - */ - protected handleIncoming(message: any): void { - if (!message.type) { - logger.warn("Received message without type", message); - return; - } - - const messageHandlers: ((m: any) => void)[] | undefined = - this.handlers[message.type]; - if (!messageHandlers) { - logger.warn("Received message without handler", message); - return; - } - - messageHandlers.forEach((h) => h(message)); - } -} - -export type SimpleReactPyClientProps = { - serverLocation?: LocationProps; - reconnectOptions?: ReconnectProps; -}; - -/** - * The location of the server. - * - * This is used to determine the location of the server's API endpoints. All endpoints - * are expected to be found at the base URL, with the following paths: - * - * - `_reactpy/stream/${route}${query}`: The websocket endpoint for the stream. - * - `_reactpy/modules`: The directory containing the dynamically loaded modules. - * - `_reactpy/assets`: The directory containing the static assets. - */ -type LocationProps = { - /** - * The base URL of the server. - * - * @default - document.location.origin - */ - url: string; - /** - * The route to the page being rendered. - * - * @default - document.location.pathname - */ - route: string; - /** - * The query string of the page being rendered. - * - * @default - document.location.search - */ - query: string; -}; - -type ReconnectProps = { - maxInterval?: number; - maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; -}; - -export class SimpleReactPyClient - extends BaseReactPyClient - implements ReactPyClient -{ - private readonly urls: ServerUrls; - private readonly socket: { current?: WebSocket }; - - constructor(props: SimpleReactPyClientProps) { - super(); - - this.urls = getServerUrls( - props.serverLocation || { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - ); - - this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, - url: this.urls.stream, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, - }); - } - - sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); - } - - loadModule(moduleName: string): Promise<ReactPyModule> { - return import(`${this.urls.modules}/${moduleName}`); - } -} - -type ServerUrls = { - base: URL; - stream: string; - modules: string; - assets: string; -}; - -function getServerUrls(props: LocationProps): ServerUrls { - const base = new URL(`${props.url || document.location.origin}/_reactpy`); - const modules = `${base}/modules`; - const assets = `${base}/assets`; - - const streamProtocol = `ws${base.protocol === "https:" ? "s" : ""}`; - const streamPath = rtrim(`${base.pathname}/stream${props.route || ""}`, "/"); - const stream = `${streamProtocol}://${base.host}${streamPath}${props.query}`; - - return { base, modules, assets, stream }; -} - -function createReconnectingWebSocket( - props: { - url: string; - readyPromise: Promise<void>; - onOpen?: () => void; - onMessage: (message: MessageEvent<any>) => void; - onClose?: () => void; - } & ReconnectProps, -) { - const { - maxInterval = 60000, - maxRetries = 50, - backoffRate = 1.1, - intervalJitter = 0.1, - } = props; - - const startInterval = 750; - let retries = 0; - let interval = startInterval; - const closed = false; - let everConnected = false; - const socket: { current?: WebSocket } = {}; - - const connect = () => { - if (closed) { - return; - } - socket.current = new WebSocket(props.url); - socket.current.onopen = () => { - everConnected = true; - logger.log("client connected"); - interval = startInterval; - retries = 0; - if (props.onOpen) { - props.onOpen(); - } - }; - socket.current.onmessage = props.onMessage; - socket.current.onclose = () => { - if (!everConnected) { - logger.log("failed to connect"); - return; - } - - logger.log("client disconnected"); - if (props.onClose) { - props.onClose(); - } - - if (retries >= maxRetries) { - return; - } - - const thisInterval = addJitter(interval, intervalJitter); - logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, - ); - setTimeout(connect, thisInterval); - interval = nextInterval(interval, backoffRate, maxInterval); - retries++; - }; - }; - - props.readyPromise.then(() => logger.log("starting client...")).then(connect); - - return socket; -} - -function nextInterval( - currentInterval: number, - backoffRate: number, - maxInterval: number, -): number { - return Math.min( - currentInterval * - // increase interval by backoff rate - backoffRate, - // don't exceed max interval - maxInterval, - ); -} - -function addJitter(interval: number, jitter: number): number { - return interval + (Math.random() * jitter * interval * 2 - jitter * interval); -} - -function rtrim(text: string, trim: string): string { - return text.replace(new RegExp(`${trim}+$`), ""); -} diff --git a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx b/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx deleted file mode 100644 index 22fa3e61d..000000000 --- a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import React, { ComponentType } from "react"; -import { ReactPyClient } from "./reactpy-client"; -import serializeEvent from "event-to-object"; - -export async function loadImportSource( - vdomImportSource: ReactPyVdomImportSource, - client: ReactPyClient, -): Promise<BindImportSource> { - let module: ReactPyModule; - if (vdomImportSource.sourceType === "URL") { - module = await import(vdomImportSource.source); - } else { - module = await client.loadModule(vdomImportSource.source); - } - if (typeof module.bind !== "function") { - throw new Error( - `${vdomImportSource.source} did not export a function 'bind'`, - ); - } - - return (node: HTMLElement) => { - const binding = module.bind(node, { - sendMessage: client.sendMessage, - onMessage: client.onMessage, - }); - if ( - !( - typeof binding.create === "function" && - typeof binding.render === "function" && - typeof binding.unmount === "function" - ) - ) { - console.error(`${vdomImportSource.source} returned an impropper binding`); - return null; - } - - return { - render: (model) => - binding.render( - createImportSourceElement({ - client, - module, - binding, - model, - currentImportSource: vdomImportSource, - }), - ), - unmount: binding.unmount, - }; - }; -} - -function createImportSourceElement(props: { - client: ReactPyClient; - module: ReactPyModule; - binding: ReactPyModuleBinding; - model: ReactPyVdom; - currentImportSource: ReactPyVdomImportSource; -}): any { - let type: any; - if (props.model.importSource) { - if ( - !isImportSourceEqual(props.currentImportSource, props.model.importSource) - ) { - console.error( - "Parent element import source " + - stringifyImportSource(props.currentImportSource) + - " does not match child's import source " + - stringifyImportSource(props.model.importSource), - ); - return null; - } else if (!props.module[props.model.tagName]) { - console.error( - "Module from source " + - stringifyImportSource(props.currentImportSource) + - ` does not export ${props.model.tagName}`, - ); - return null; - } else { - type = props.module[props.model.tagName]; - } - } else { - type = props.model.tagName; - } - return props.binding.create( - type, - createAttributes(props.model, props.client), - createChildren(props.model, (child) => - createImportSourceElement({ - ...props, - model: child, - }), - ), - ); -} - -function isImportSourceEqual( - source1: ReactPyVdomImportSource, - source2: ReactPyVdomImportSource, -) { - return ( - source1.source === source2.source && - source1.sourceType === source2.sourceType - ); -} - -function stringifyImportSource(importSource: ReactPyVdomImportSource) { - return JSON.stringify({ - source: importSource.source, - sourceType: importSource.sourceType, - }); -} - -export function createChildren<Child>( - model: ReactPyVdom, - createChild: (child: ReactPyVdom) => Child, -): (Child | string)[] { - if (!model.children) { - return []; - } else { - return model.children.map((child) => { - switch (typeof child) { - case "object": - return createChild(child); - case "string": - return child; - } - }); - } -} - -export function createAttributes( - model: ReactPyVdom, - client: ReactPyClient, -): { [key: string]: any } { - return Object.fromEntries( - Object.entries({ - // Normal HTML attributes - ...model.attributes, - // Construct event handlers - ...Object.fromEntries( - Object.entries(model.eventHandlers || {}).map(([name, handler]) => - createEventHandler(client, name, handler), - ), - ), - // Convert snake_case to camelCase names - }).map(normalizeAttribute), - ); -} - -function createEventHandler( - client: ReactPyClient, - name: string, - { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, -): [string, () => void] { - return [ - name, - function (...args: any[]) { - const data = Array.from(args).map((value) => { - if (!(typeof value === "object" && value.nativeEvent)) { - return value; - } - const event = value as React.SyntheticEvent<any>; - if (preventDefault) { - event.preventDefault(); - } - if (stopPropagation) { - event.stopPropagation(); - } - return serializeEvent(event.nativeEvent); - }); - client.sendMessage({ type: "layout-event", data, target }); - }, - ]; -} - -function normalizeAttribute([key, value]: [string, any]): [string, any] { - let normKey = key; - let normValue = value; - - if (key === "style" && typeof value === "object") { - normValue = Object.fromEntries( - Object.entries(value).map(([k, v]) => [snakeToCamel(k), v]), - ); - } else if ( - key.startsWith("data_") || - key.startsWith("aria_") || - DASHED_HTML_ATTRS.includes(key) - ) { - normKey = key.split("_").join("-"); - } else { - normKey = snakeToCamel(key); - } - return [normKey, normValue]; -} - -function snakeToCamel(str: string): string { - return str.replace(/([_][a-z])/g, (group) => - group.toUpperCase().replace("_", ""), - ); -} - -// see list of HTML attributes with dashes in them: -// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list -const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"]; - -export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; - -export type ReactPyVdom = { - tagName: string; - key?: string; - attributes?: { [key: string]: string }; - children?: (ReactPyVdom | string)[]; - error?: string; - eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; - importSource?: ReactPyVdomImportSource; -}; - -export type ReactPyVdomEventHandler = { - target: string; - preventDefault?: boolean; - stopPropagation?: boolean; -}; - -export type ReactPyVdomImportSource = { - source: string; - sourceType?: "URL" | "NAME"; - fallback?: string | ReactPyVdom; - unmountBeforeUpdate?: boolean; -}; - -export type ReactPyModule = { - bind: ( - node: HTMLElement, - context: ReactPyModuleBindingContext, - ) => ReactPyModuleBinding; -} & { [key: string]: any }; - -export type ReactPyModuleBindingContext = { - sendMessage: ReactPyClient["sendMessage"]; - onMessage: ReactPyClient["onMessage"]; -}; - -export type ReactPyModuleBinding = { - create: ( - type: any, - props?: any, - children?: (any | string | ReactPyVdom)[], - ) => any; - render: (element: any) => void; - unmount: () => void; -}; - -export type BindImportSource = ( - node: HTMLElement, -) => ImportSourceBinding | null; - -export type ImportSourceBinding = { - render: (model: ReactPyVdom) => void; - unmount: () => void; -}; diff --git a/src/js/packages/@reactpy/client/tsconfig.json b/src/js/packages/@reactpy/client/tsconfig.json deleted file mode 100644 index 2e1483e10..000000000 --- a/src/js/packages/@reactpy/client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../../tsconfig.package.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "composite": true - }, - "include": ["src"], - "references": [ - { - "path": "../../event-to-object" - } - ] -} diff --git a/src/js/packages/event-to-object/package.json b/src/js/packages/event-to-object/package.json deleted file mode 100644 index eaeb99343..000000000 --- a/src/js/packages/event-to-object/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "author": "Ryan Morshead", - "license": "MIT", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "name": "event-to-object", - "description": "Convert native events to JSON serializable objects", - "type": "module", - "version": "0.1.2", - "dependencies": { - "json-pointer": "^0.6.2" - }, - "devDependencies": { - "happy-dom": "^8.9.0", - "lodash": "^4.17.21", - "tsm": "^2.0.0", - "typescript": "^4.9.5", - "uvu": "^0.5.1" - }, - "repository": { - "type": "git", - "url": "https://github.com/reactive-python/reactpy" - }, - "scripts": { - "build": "tsc -b", - "test": "npm run check:tests", - "check:tests": "uvu -r tsm tests", - "check:types": "tsc --noEmit" - } -} diff --git a/src/js/packages/event-to-object/src/events.ts b/src/js/packages/event-to-object/src/events.ts deleted file mode 100644 index cef37ff09..000000000 --- a/src/js/packages/event-to-object/src/events.ts +++ /dev/null @@ -1,258 +0,0 @@ -// TODO -type FileListObject = any; -type DataTransferItemListObject = any; - -export type EventToObjectMap = { - event: [Event, EventObject]; - animation: [AnimationEvent, AnimationEventObject]; - clipboard: [ClipboardEvent, ClipboardEventObject]; - composition: [CompositionEvent, CompositionEventObject]; - devicemotion: [DeviceMotionEvent, DeviceMotionEventObject]; - deviceorientation: [DeviceOrientationEvent, DeviceOrientationEventObject]; - drag: [DragEvent, DragEventObject]; - focus: [FocusEvent, FocusEventObject]; - formdata: [FormDataEvent, FormDataEventObject]; - gamepad: [GamepadEvent, GamepadEventObject]; - input: [InputEvent, InputEventObject]; - keyboard: [KeyboardEvent, KeyboardEventObject]; - mouse: [MouseEvent, MouseEventObject]; - pointer: [PointerEvent, PointerEventObject]; - submit: [SubmitEvent, SubmitEventObject]; - touch: [TouchEvent, TouchEventObject]; - transition: [TransitionEvent, TransitionEventObject]; - ui: [UIEvent, UIEventObject]; - wheel: [WheelEvent, WheelEventObject]; -}; - -export interface EventObject { - bubbles: boolean; - composed: boolean; - currentTarget: ElementObject | null; - defaultPrevented: boolean; - eventPhase: number; - isTrusted: boolean; - target: ElementObject | null; - timeStamp: DOMHighResTimeStamp; - type: string; - selection: SelectionObject | null; -} - -export interface SubmitEventObject extends EventObject { - submitter: ElementObject; -} - -export interface InputEventObject extends UIEventObject { - data: string | null; - dataTransfer: DataTransferObject | null; - isComposing: boolean; - inputType: string; -} - -export interface GamepadEventObject extends EventObject { - gamepad: GamepadObject; -} - -export interface GamepadObject { - axes: number[]; - buttons: GamepadButtonObject[]; - connected: boolean; - hapticActuators: GamepadHapticActuatorObject[]; - id: string; - index: number; - mapping: GamepadMappingType; - timestamp: DOMHighResTimeStamp; -} - -export interface GamepadButtonObject { - pressed: boolean; - touched: boolean; - value: number; -} -export interface GamepadHapticActuatorObject { - type: string; -} - -export interface DragEventObject extends MouseEventObject { - /** Returns the DataTransfer object for the event. */ - readonly dataTransfer: DataTransferObject | null; -} - -export interface DeviceMotionEventObject extends EventObject { - acceleration: DeviceAccelerationObject | null; - accelerationIncludingGravity: DeviceAccelerationObject | null; - interval: number; - rotationRate: DeviceRotationRateObject | null; -} - -export interface DeviceAccelerationObject { - x: number | null; - y: number | null; - z: number | null; -} - -export interface DeviceRotationRateObject { - alpha: number | null; - beta: number | null; - gamma: number | null; -} - -export interface DeviceOrientationEventObject extends EventObject { - absolute: boolean; - alpha: number | null; - beta: number | null; - gamma: number | null; -} - -export interface MouseEventObject extends EventObject { - altKey: boolean; - button: number; - buttons: number; - clientX: number; - clientY: number; - ctrlKey: boolean; - metaKey: boolean; - movementX: number; - movementY: number; - offsetX: number; - offsetY: number; - pageX: number; - pageY: number; - relatedTarget: ElementObject | null; - screenX: number; - screenY: number; - shiftKey: boolean; - x: number; - y: number; -} - -export interface FormDataEventObject extends EventObject { - formData: FormDataObject; -} - -export type FormDataObject = [string, string | FileObject][]; - -export interface AnimationEventObject extends EventObject { - animationName: string; - elapsedTime: number; - pseudoElement: string; -} - -export interface ClipboardEventObject extends EventObject { - clipboardData: DataTransferObject | null; -} - -export interface UIEventObject extends EventObject { - detail: number; -} - -/** The DOM CompositionEvent represents events that occur due to the user indirectly - * entering text. */ -export interface CompositionEventObject extends UIEventObject { - data: string; -} - -export interface KeyboardEventObject extends UIEventObject { - altKey: boolean; - code: string; - ctrlKey: boolean; - isComposing: boolean; - key: string; - location: number; - metaKey: boolean; - repeat: boolean; - shiftKey: boolean; -} - -export interface FocusEventObject extends UIEventObject { - relatedTarget: ElementObject | null; -} - -export interface TouchEventObject extends UIEventObject { - altKey: boolean; - changedTouches: TouchObject[]; - ctrlKey: boolean; - metaKey: boolean; - shiftKey: boolean; - targetTouches: TouchObject[]; - touches: TouchObject[]; -} - -export interface PointerEventObject extends MouseEventObject { - height: number; - isPrimary: boolean; - pointerId: number; - pointerType: string; - pressure: number; - tangentialPressure: number; - tiltX: number; - tiltY: number; - twist: number; - width: number; -} - -export interface TransitionEventObject extends EventObject { - elapsedTime: number; - propertyName: string; - pseudoElement: string; -} - -export interface WheelEventObject extends MouseEventObject { - readonly deltaMode: number; - readonly deltaX: number; - readonly deltaY: number; - readonly deltaZ: number; -} - -export interface TouchObject { - clientX: number; - clientY: number; - force: number; - identifier: number; - pageX: number; - pageY: number; - radiusX: number; - radiusY: number; - rotationAngle: number; - screenX: number; - screenY: number; - target: ElementObject; -} - -export interface DataTransferObject { - dropEffect: "none" | "copy" | "link" | "move"; - effectAllowed: - | "none" - | "copy" - | "copyLink" - | "copyMove" - | "link" - | "linkMove" - | "move" - | "all" - | "uninitialized"; - files: FileListObject; - items: DataTransferItemListObject; - types: string[]; -} - -export interface SelectionObject { - anchorNode: ElementObject | null; - anchorOffset: number; - focusNode: ElementObject | null; - focusOffset: number; - isCollapsed: boolean; - rangeCount: number; - type: string; - selectedText: string; -} - -export interface ElementObject { - value?: string; - textContent?: string; -} - -export interface FileObject { - name: string; - size: number; - type: string; -} diff --git a/src/js/packages/event-to-object/src/index.ts b/src/js/packages/event-to-object/src/index.ts deleted file mode 100644 index 9a40a2128..000000000 --- a/src/js/packages/event-to-object/src/index.ts +++ /dev/null @@ -1,427 +0,0 @@ -import * as e from "./events"; - -export default function convert<E extends Event>( - event: E, -): - | { - [K in keyof e.EventToObjectMap]: e.EventToObjectMap[K] extends [ - E, - infer P, - ] - ? P - : never; - }[keyof e.EventToObjectMap] - | null { - return event.type in eventConverters - ? eventConverters[event.type](event) - : convertEvent(event); -} - -const convertEvent = (event: Event): e.EventObject => ({ - /** Returns true or false depending on how event was initialized. True if event goes - * through its target's ancestors in reverse tree order, and false otherwise. */ - bubbles: event.bubbles, - composed: event.composed, - currentTarget: convertElement(event.currentTarget), - defaultPrevented: event.defaultPrevented, - eventPhase: event.eventPhase, - isTrusted: event.isTrusted, - target: convertElement(event.target), - timeStamp: event.timeStamp, - type: event.type, - selection: convertSelection(window.getSelection()), -}); - -const convertClipboardEvent = ( - event: ClipboardEvent, -): e.ClipboardEventObject => ({ - ...convertEvent(event), - clipboardData: convertDataTransferObject(event.clipboardData), -}); - -const convertCompositionEvent = ( - event: CompositionEvent, -): e.CompositionEventObject => ({ - ...convertUiEvent(event), - data: event.data, -}); - -const convertInputEvent = (event: InputEvent): e.InputEventObject => ({ - ...convertUiEvent(event), - data: event.data, - inputType: event.inputType, - dataTransfer: convertDataTransferObject(event.dataTransfer), - isComposing: event.isComposing, -}); - -const convertKeyboardEvent = (event: KeyboardEvent): e.KeyboardEventObject => ({ - ...convertUiEvent(event), - code: event.code, - isComposing: event.isComposing, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - key: event.key, - location: event.location, - metaKey: event.metaKey, - repeat: event.repeat, - shiftKey: event.shiftKey, -}); - -const convertMouseEvent = (event: MouseEvent): e.MouseEventObject => ({ - ...convertEvent(event), - altKey: event.altKey, - button: event.button, - buttons: event.buttons, - clientX: event.clientX, - clientY: event.clientY, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - pageX: event.pageX, - pageY: event.pageY, - screenX: event.screenX, - screenY: event.screenY, - shiftKey: event.shiftKey, - movementX: event.movementX, - movementY: event.movementY, - offsetX: event.offsetX, - offsetY: event.offsetY, - x: event.x, - y: event.y, - relatedTarget: convertElement(event.relatedTarget), -}); - -const convertTouchEvent = (event: TouchEvent): e.TouchEventObject => ({ - ...convertUiEvent(event), - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - touches: Array.from(event.touches).map(convertTouch), - changedTouches: Array.from(event.changedTouches).map(convertTouch), - targetTouches: Array.from(event.targetTouches).map(convertTouch), -}); - -const convertUiEvent = (event: UIEvent): e.UIEventObject => ({ - ...convertEvent(event), - detail: event.detail, -}); - -const convertAnimationEvent = ( - event: AnimationEvent, -): e.AnimationEventObject => ({ - ...convertEvent(event), - animationName: event.animationName, - pseudoElement: event.pseudoElement, - elapsedTime: event.elapsedTime, -}); - -const convertTransitionEvent = ( - event: TransitionEvent, -): e.TransitionEventObject => ({ - ...convertEvent(event), - propertyName: event.propertyName, - pseudoElement: event.pseudoElement, - elapsedTime: event.elapsedTime, -}); - -const convertFocusEvent = (event: FocusEvent): e.FocusEventObject => ({ - ...convertUiEvent(event), - relatedTarget: convertElement(event.relatedTarget), -}); - -const convertDeviceOrientationEvent = ( - event: DeviceOrientationEvent, -): e.DeviceOrientationEventObject => ({ - ...convertEvent(event), - absolute: event.absolute, - alpha: event.alpha, - beta: event.beta, - gamma: event.gamma, -}); - -const convertDragEvent = (event: DragEvent): e.DragEventObject => ({ - ...convertMouseEvent(event), - dataTransfer: convertDataTransferObject(event.dataTransfer), -}); - -const convertGamepadEvent = (event: GamepadEvent): e.GamepadEventObject => ({ - ...convertEvent(event), - gamepad: convertGamepad(event.gamepad), -}); - -const convertPointerEvent = (event: PointerEvent): e.PointerEventObject => ({ - ...convertMouseEvent(event), - pointerId: event.pointerId, - width: event.width, - height: event.height, - pressure: event.pressure, - tiltX: event.tiltX, - tiltY: event.tiltY, - pointerType: event.pointerType, - isPrimary: event.isPrimary, - tangentialPressure: event.tangentialPressure, - twist: event.twist, -}); - -const convertWheelEvent = (event: WheelEvent): e.WheelEventObject => ({ - ...convertMouseEvent(event), - deltaMode: event.deltaMode, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaZ: event.deltaZ, -}); - -const convertSubmitEvent = (event: SubmitEvent): e.SubmitEventObject => ({ - ...convertEvent(event), - submitter: convertElement(event.submitter), -}); - -const eventConverters: { [key: string]: (event: any) => any } = { - // animation events - animationcancel: convertAnimationEvent, - animationend: convertAnimationEvent, - animationiteration: convertAnimationEvent, - animationstart: convertAnimationEvent, - // input events - beforeinput: convertInputEvent, - // composition events - compositionend: convertCompositionEvent, - compositionstart: convertCompositionEvent, - compositionupdate: convertCompositionEvent, - // clipboard events - copy: convertClipboardEvent, - cut: convertClipboardEvent, - paste: convertClipboardEvent, - // device orientation events - deviceorientation: convertDeviceOrientationEvent, - // drag events - drag: convertDragEvent, - dragend: convertDragEvent, - dragenter: convertDragEvent, - dragleave: convertDragEvent, - dragover: convertDragEvent, - dragstart: convertDragEvent, - drop: convertDragEvent, - // ui events - error: convertUiEvent, - // focus events - blur: convertFocusEvent, - focus: convertFocusEvent, - focusin: convertFocusEvent, - focusout: convertFocusEvent, - // gamepad events - gamepadconnected: convertGamepadEvent, - gamepaddisconnected: convertGamepadEvent, - // keyboard events - keydown: convertKeyboardEvent, - keypress: convertKeyboardEvent, - keyup: convertKeyboardEvent, - // mouse events - auxclick: convertMouseEvent, - click: convertMouseEvent, - dblclick: convertMouseEvent, - contextmenu: convertMouseEvent, - mousedown: convertMouseEvent, - mouseenter: convertMouseEvent, - mouseleave: convertMouseEvent, - mousemove: convertMouseEvent, - mouseout: convertMouseEvent, - mouseover: convertMouseEvent, - mouseup: convertMouseEvent, - scroll: convertMouseEvent, - // pointer events - gotpointercapture: convertPointerEvent, - lostpointercapture: convertPointerEvent, - pointercancel: convertPointerEvent, - pointerdown: convertPointerEvent, - pointerenter: convertPointerEvent, - pointerleave: convertPointerEvent, - pointerlockchange: convertPointerEvent, - pointerlockerror: convertPointerEvent, - pointermove: convertPointerEvent, - pointerout: convertPointerEvent, - pointerover: convertPointerEvent, - pointerup: convertPointerEvent, - // submit events - submit: convertSubmitEvent, - // touch events - touchcancel: convertTouchEvent, - touchend: convertTouchEvent, - touchmove: convertTouchEvent, - touchstart: convertTouchEvent, - // transition events - transitioncancel: convertTransitionEvent, - transitionend: convertTransitionEvent, - transitionrun: convertTransitionEvent, - transitionstart: convertTransitionEvent, - // wheel events - wheel: convertWheelEvent, -}; - -function convertElement(element: EventTarget | HTMLElement | null): any { - if (!element || !("tagName" in element)) { - return null; - } - - const htmlElement = element as HTMLElement; - - return { - ...convertGenericElement(htmlElement), - ...(htmlElement.tagName in elementConverters - ? elementConverters[htmlElement.tagName](htmlElement) - : {}), - }; -} - -const convertGenericElement = (element: HTMLElement) => ({ - tagName: element.tagName, - boundingClientRect: { ...element.getBoundingClientRect() }, -}); - -const convertMediaElement = (element: HTMLMediaElement) => ({ - currentTime: element.currentTime, - duration: element.duration, - ended: element.ended, - error: element.error, - seeking: element.seeking, - volume: element.volume, -}); - -const elementConverters: { [key: string]: (element: any) => any } = { - AUDIO: convertMediaElement, - BUTTON: (element: HTMLButtonElement) => ({ value: element.value }), - DATA: (element: HTMLDataElement) => ({ value: element.value }), - DATALIST: (element: HTMLDataListElement) => ({ - options: Array.from(element.options).map(elementConverters["OPTION"]), - }), - DIALOG: (element: HTMLDialogElement) => ({ - returnValue: element.returnValue, - }), - FIELDSET: (element: HTMLFieldSetElement) => ({ - elements: Array.from(element.elements).map(convertElement), - }), - FORM: (element: HTMLFormElement) => ({ - elements: Array.from(element.elements).map(convertElement), - }), - INPUT: (element: HTMLInputElement) => ({ value: element.value }), - METER: (element: HTMLMeterElement) => ({ value: element.value }), - OPTION: (element: HTMLOptionElement) => ({ value: element.value }), - OUTPUT: (element: HTMLOutputElement) => ({ value: element.value }), - PROGRESS: (element: HTMLProgressElement) => ({ value: element.value }), - SELECT: (element: HTMLSelectElement) => ({ value: element.value }), - TEXTAREA: (element: HTMLTextAreaElement) => ({ value: element.value }), - VIDEO: convertMediaElement, -}; - -const convertGamepad = (gamepad: Gamepad): e.GamepadObject => ({ - axes: Array.from(gamepad.axes), - buttons: Array.from(gamepad.buttons).map(convertGamepadButton), - connected: gamepad.connected, - id: gamepad.id, - index: gamepad.index, - mapping: gamepad.mapping, - timestamp: gamepad.timestamp, - hapticActuators: Array.from(gamepad.hapticActuators).map( - convertGamepadHapticActuator, - ), -}); - -const convertGamepadButton = ( - button: GamepadButton, -): e.GamepadButtonObject => ({ - pressed: button.pressed, - touched: button.touched, - value: button.value, -}); - -const convertGamepadHapticActuator = ( - actuator: GamepadHapticActuator, -): e.GamepadHapticActuatorObject => ({ - type: actuator.type, -}); - -const convertFile = (file: File) => ({ - lastModified: file.lastModified, - name: file.name, - size: file.size, - type: file.type, -}); - -function convertDataTransferObject( - dataTransfer: DataTransfer | null, -): e.DataTransferObject | null { - if (!dataTransfer) { - return null; - } - const { dropEffect, effectAllowed, files, items, types } = dataTransfer; - return { - dropEffect, - effectAllowed, - files: Array.from(files).map(convertFile), - items: Array.from(items).map((item) => ({ - kind: item.kind, - type: item.type, - })), - types: Array.from(types), - }; -} - -function convertSelection( - selection: Selection | null, -): e.SelectionObject | null { - if (!selection) { - return null; - } - const { - type, - anchorNode, - anchorOffset, - focusNode, - focusOffset, - isCollapsed, - rangeCount, - } = selection; - if (type === "None") { - return null; - } - return { - type, - anchorNode: convertElement(anchorNode), - anchorOffset, - focusNode: convertElement(focusNode), - focusOffset, - isCollapsed, - rangeCount, - selectedText: selection.toString(), - }; -} - -function convertTouch({ - identifier, - pageX, - pageY, - screenX, - screenY, - clientX, - clientY, - force, - radiusX, - radiusY, - rotationAngle, - target, -}: Touch): e.TouchObject { - return { - identifier, - pageX, - pageY, - screenX, - screenY, - clientX, - clientY, - force, - radiusX, - radiusY, - rotationAngle, - target: convertElement(target), - }; -} diff --git a/src/js/packages/event-to-object/tests/event-to-object.test.ts b/src/js/packages/event-to-object/tests/event-to-object.test.ts deleted file mode 100644 index b7b8c68af..000000000 --- a/src/js/packages/event-to-object/tests/event-to-object.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -// @ts-ignore -import { window } from "./tooling/setup"; -import { test } from "uvu"; -import { Event } from "happy-dom"; -import { checkEventConversion } from "./tooling/check"; -import { - mockElementObject, - mockGamepad, - mockTouch, - mockTouchObject, -} from "./tooling/mock"; - -type SimpleTestCase<E extends Event> = { - types: string[]; - description: string; - givenEventType: new (type: string) => E; - expectedConversion: any; - initGivenEvent?: (event: E) => void; -}; - -const simpleTestCases: SimpleTestCase<any>[] = [ - { - types: [ - "animationcancel", - "animationend", - "animationiteration", - "animationstart", - ], - description: "animation event", - givenEventType: window.AnimationEvent, - expectedConversion: { - animationName: "", - pseudoElement: "", - elapsedTime: 0, - }, - }, - { - types: ["beforeinput"], - description: "event", - givenEventType: window.InputEvent, - expectedConversion: { - detail: 0, - data: "", - inputType: "", - dataTransfer: null, - isComposing: false, - }, - }, - { - types: ["compositionend", "compositionstart", "compositionupdate"], - description: "composition event", - givenEventType: window.CompositionEvent, - expectedConversion: { - data: undefined, - detail: undefined, - }, - }, - { - types: ["copy", "cut", "paste"], - description: "clipboard event", - givenEventType: window.ClipboardEvent, - expectedConversion: { clipboardData: null }, - }, - { - types: [ - "drag", - "dragend", - "dragenter", - "dragleave", - "dragover", - "dragstart", - "drop", - ], - description: "drag event", - givenEventType: window.DragEvent, - expectedConversion: { - altKey: undefined, - button: undefined, - buttons: undefined, - clientX: undefined, - clientY: undefined, - ctrlKey: undefined, - dataTransfer: null, - metaKey: undefined, - movementX: undefined, - movementY: undefined, - offsetX: undefined, - offsetY: undefined, - pageX: undefined, - pageY: undefined, - relatedTarget: null, - screenX: undefined, - screenY: undefined, - shiftKey: undefined, - x: undefined, - y: undefined, - }, - }, - { - types: ["error"], - description: "event", - givenEventType: window.ErrorEvent, - expectedConversion: { detail: 0 }, - }, - { - types: ["blur", "focus", "focusin", "focusout"], - description: "focus event", - givenEventType: window.FocusEvent, - expectedConversion: { - relatedTarget: null, - detail: 0, - }, - }, - { - types: ["gamepadconnected", "gamepaddisconnected"], - description: "gamepad event", - givenEventType: window.GamepadEvent, - expectedConversion: { gamepad: mockGamepad }, - initGivenEvent: (event) => { - event.gamepad = mockGamepad; - }, - }, - { - types: ["keydown", "keypress", "keyup"], - description: "keyboard event", - givenEventType: window.KeyboardEvent, - expectedConversion: { - altKey: false, - code: "", - ctrlKey: false, - isComposing: false, - key: "", - location: 0, - metaKey: false, - repeat: false, - shiftKey: false, - detail: 0, - }, - }, - { - types: [ - "click", - "auxclick", - "dblclick", - "mousedown", - "mouseenter", - "mouseleave", - "mousemove", - "mouseout", - "mouseover", - "mouseup", - "scroll", - ], - description: "mouse event", - givenEventType: window.MouseEvent, - expectedConversion: { - altKey: false, - button: 0, - buttons: 0, - clientX: 0, - clientY: 0, - ctrlKey: false, - metaKey: false, - movementX: 0, - movementY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: 0, - screenY: 0, - shiftKey: false, - x: undefined, - y: undefined, - }, - }, - { - types: [ - "auxclick", - "click", - "contextmenu", - "dblclick", - "mousedown", - "mouseenter", - "mouseleave", - "mousemove", - "mouseout", - "mouseover", - "mouseup", - ], - description: "mouse event", - givenEventType: window.MouseEvent, - expectedConversion: { - altKey: false, - button: 0, - buttons: 0, - clientX: 0, - clientY: 0, - ctrlKey: false, - metaKey: false, - movementX: 0, - movementY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: 0, - screenY: 0, - shiftKey: false, - x: undefined, - y: undefined, - }, - }, - { - types: [ - "gotpointercapture", - "lostpointercapture", - "pointercancel", - "pointerdown", - "pointerenter", - "pointerleave", - "pointerlockchange", - "pointerlockerror", - "pointermove", - "pointerout", - "pointerover", - "pointerup", - ], - description: "pointer event", - givenEventType: window.PointerEvent, - expectedConversion: { - altKey: false, - button: 0, - buttons: 0, - clientX: 0, - clientY: 0, - ctrlKey: false, - metaKey: false, - movementX: 0, - movementY: 0, - offsetX: 0, - offsetY: 0, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: 0, - screenY: 0, - shiftKey: false, - x: undefined, - y: undefined, - pointerId: 0, - pointerType: "", - pressure: 0, - tiltX: 0, - tiltY: 0, - width: 0, - height: 0, - isPrimary: false, - twist: 0, - tangentialPressure: 0, - }, - }, - { - types: ["submit"], - description: "event", - givenEventType: window.Event, - expectedConversion: { submitter: null }, - initGivenEvent: (event) => { - event.submitter = null; - }, - }, - { - types: ["touchcancel", "touchend", "touchmove", "touchstart"], - description: "touch event", - givenEventType: window.TouchEvent, - expectedConversion: { - altKey: undefined, - changedTouches: [mockTouchObject], - ctrlKey: undefined, - metaKey: undefined, - targetTouches: [mockTouchObject], - touches: [mockTouchObject], - detail: undefined, - shiftKey: undefined, - }, - initGivenEvent: (event) => { - event.changedTouches = [mockTouch]; - event.targetTouches = [mockTouch]; - event.touches = [mockTouch]; - }, - }, - { - types: [ - "transitioncancel", - "transitionend", - "transitionrun", - "transitionstart", - ], - description: "transition event", - givenEventType: window.TransitionEvent, - expectedConversion: { - propertyName: undefined, - elapsedTime: undefined, - pseudoElement: undefined, - }, - }, - { - types: ["wheel"], - description: "wheel event", - givenEventType: window.WheelEvent, - expectedConversion: { - altKey: undefined, - button: undefined, - buttons: undefined, - clientX: undefined, - clientY: undefined, - ctrlKey: undefined, - deltaMode: 0, - deltaX: 0, - deltaY: 0, - deltaZ: 0, - metaKey: undefined, - movementX: undefined, - movementY: undefined, - offsetX: undefined, - offsetY: undefined, - pageX: 0, - pageY: 0, - relatedTarget: null, - screenX: undefined, - screenY: undefined, - shiftKey: undefined, - x: undefined, - y: undefined, - }, - }, -]; - -simpleTestCases.forEach((testCase) => { - testCase.types.forEach((type) => { - test(`converts ${type} ${testCase.description}`, () => { - const event = new testCase.givenEventType(type); - if (testCase.initGivenEvent) { - testCase.initGivenEvent(event); - } - checkEventConversion(event, testCase.expectedConversion); - }); - }); -}); - -test("adds text of current selection", () => { - document.body.innerHTML = ` - <div> - <p id="start"><span>START</span></p> - <p>MIDDLE</p> - <p id="end"><span>END</span></p> - </div> - `; - const start = document.getElementById("start"); - const end = document.getElementById("end"); - window.getSelection()!.setBaseAndExtent(start!, 0, end!, 0); - checkEventConversion(new window.Event("fake"), { - type: "fake", - selection: { - type: "Range", - anchorNode: { ...mockElementObject, tagName: "P" }, - anchorOffset: 0, - focusNode: { ...mockElementObject, tagName: "P" }, - focusOffset: 0, - isCollapsed: false, - rangeCount: 1, - selectedText: "START\n MIDDLE\n ", - }, - eventPhase: undefined, - isTrusted: undefined, - }); -}); - -test.run(); diff --git a/src/js/packages/event-to-object/tests/tooling/check.ts b/src/js/packages/event-to-object/tests/tooling/check.ts deleted file mode 100644 index 33ff5ed5b..000000000 --- a/src/js/packages/event-to-object/tests/tooling/check.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as assert from "uvu/assert"; -import { Event } from "happy-dom"; -// @ts-ignore -import lodash from "lodash"; -import convert from "../../src/index"; - -export function checkEventConversion( - givenEvent: Event, - expectedConversion: any, -): void { - const actualSerializedEvent = convert( - // @ts-ignore - givenEvent, - ); - - if (!actualSerializedEvent) { - assert.equal(actualSerializedEvent, expectedConversion); - return; - } - - // too hard to compare - assert.equal(typeof actualSerializedEvent.timeStamp, "number"); - - assert.equal( - actualSerializedEvent, - lodash.merge( - { timeStamp: actualSerializedEvent.timeStamp, type: givenEvent.type }, - expectedConversionDefaults, - expectedConversion, - ), - ); - - // verify result is JSON serializable - JSON.stringify(actualSerializedEvent); -} - -const expectedConversionDefaults = { - target: null, - currentTarget: null, - bubbles: false, - composed: false, - defaultPrevented: false, - eventPhase: undefined, - isTrusted: undefined, - selection: null, -}; diff --git a/src/js/packages/event-to-object/tests/tooling/mock.ts b/src/js/packages/event-to-object/tests/tooling/mock.ts deleted file mode 100644 index 81e506500..000000000 --- a/src/js/packages/event-to-object/tests/tooling/mock.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const mockBoundingRect = { - left: 0, - top: 0, - right: 0, - bottom: 0, - x: 0, - y: 0, - height: 0, - width: 0, -}; - -export const mockElementObject = { - tagName: null, - boundingClientRect: mockBoundingRect, -}; - -export const mockElement = { - tagName: null, - getBoundingClientRect: () => mockBoundingRect, -}; - -export const mockGamepad = { - id: "test", - index: 0, - connected: true, - mapping: "standard", - axes: [], - buttons: [ - { - pressed: false, - touched: false, - value: 0, - }, - ], - hapticActuators: [ - { - type: "vibration", - }, - ], - timestamp: undefined, -}; - -export const mockTouch = { - identifier: 0, - pageX: 0, - pageY: 0, - screenX: 0, - screenY: 0, - clientX: 0, - clientY: 0, - force: 0, - radiusX: 0, - radiusY: 0, - rotationAngle: 0, - target: mockElement, -}; - -export const mockTouchObject = { - ...mockTouch, - target: mockElementObject, -}; diff --git a/src/js/packages/event-to-object/tests/tooling/setup.js b/src/js/packages/event-to-object/tests/tooling/setup.js deleted file mode 100644 index 213578046..000000000 --- a/src/js/packages/event-to-object/tests/tooling/setup.js +++ /dev/null @@ -1,22 +0,0 @@ -import { test } from "uvu"; -import { Window } from "happy-dom"; - -export const window = new Window(); - -export function setup() { - global.window = window; - global.document = window.document; - global.navigator = window.navigator; - global.getComputedStyle = window.getComputedStyle; - global.requestAnimationFrame = null; -} - -export function reset() { - window.document.title = ""; - window.document.head.innerHTML = ""; - window.document.body.innerHTML = "<main></main>"; - window.getSelection().removeAllRanges(); -} - -test.before(setup); -test.before.each(reset); diff --git a/src/js/packages/event-to-object/tsconfig.json b/src/js/packages/event-to-object/tsconfig.json deleted file mode 100644 index b9a031fa9..000000000 --- a/src/js/packages/event-to-object/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.package.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "composite": true - }, - "include": ["src"] -} diff --git a/src/js/packages/event-to-object/tsconfig.tests.json b/src/js/packages/event-to-object/tsconfig.tests.json deleted file mode 100644 index 33be69a56..000000000 --- a/src/js/packages/event-to-object/tsconfig.tests.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "esnext", - "allowJs": false, - "skipLibCheck": false, - "esModuleInterop": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true - } -} diff --git a/src/js/tsconfig.package.json b/src/js/tsconfig.package.json deleted file mode 100644 index 9e7fe5f74..000000000 --- a/src/js/tsconfig.package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "allowJs": false, - "allowSyntheticDefaultImports": true, - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "jsx": "react", - "lib": ["DOM", "DOM.Iterable", "esnext"], - "module": "esnext", - "moduleResolution": "node", - "noEmitOnError": true, - "noUnusedLocals": true, - "resolveJsonModule": true, - "skipLibCheck": false, - "sourceMap": true, - "strict": true, - "target": "esnext" - } -} diff --git a/src/py/reactpy/.gitignore b/src/py/reactpy/.gitignore deleted file mode 100644 index 0499d7590..000000000 --- a/src/py/reactpy/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.coverage.* - -# --- Build Artifacts --- -reactpy/_static diff --git a/src/py/reactpy/MANIFEST.in b/src/py/reactpy/MANIFEST.in deleted file mode 100644 index b989938fa..000000000 --- a/src/py/reactpy/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -recursive-include src/reactpy/_client * -recursive-include src/reactpy/web/templates * -include src/reactpy/py.typed diff --git a/src/py/reactpy/README.md b/src/py/reactpy/README.md deleted file mode 100644 index 910a573a5..000000000 --- a/src/py/reactpy/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# <img src="https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg" align="left" height="45"/> ReactPy - -<p> - <a href="https://github.com/reactive-python/reactpy/actions"> - <img src="https://github.com/reactive-python/reactpy/workflows/test/badge.svg?event=push"> - </a> - <a href="https://pypi.org/project/reactpy/"> - <img src="https://img.shields.io/pypi/v/reactpy.svg?label=PyPI"> - </a> - <a href="https://github.com/reactive-python/reactpy/blob/main/LICENSE"> - <img src="https://img.shields.io/badge/License-MIT-purple.svg"> - </a> - <a href="https://reactpy.dev/"> - <img src="https://img.shields.io/website?down_message=offline&label=Docs&logo=read-the-docs&logoColor=white&up_message=online&url=https%3A%2F%2Freactpy.dev%2Fdocs%2Findex.html"> - </a> - <a href="https://discord.gg/uNb5P4hA9X"> - <img src="https://img.shields.io/discord/1111078259854168116?label=Discord&logo=discord"> - </a> -</p> - ---- - -[ReactPy](https://reactpy.dev/) is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in [ReactJS](https://reactjs.org/). Designed with simplicity in mind, ReactPy can be used by those without web development experience while also being powerful enough to grow with your ambitions. diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml deleted file mode 100644 index 659ddbf94..000000000 --- a/src/py/reactpy/pyproject.toml +++ /dev/null @@ -1,175 +0,0 @@ -[build-system] -requires = ["hatchling", "hatch-build-scripts>=0.0.4"] -build-backend = "hatchling.build" - -# --- Project -------------------------------------------------------------------------- - -[project] -name = "reactpy" -dynamic = ["version"] -description = 'Reactive user interfaces with pure Python' -readme = "README.md" -requires-python = ">=3.9" -license = "MIT" -keywords = ["react", "javascript", "reactpy", "component"] -authors = [ - { name = "Ryan Morshead", email = "ryan.morshead@gmail.com" }, -] -classifiers = [ - "Development Status :: 4 - Beta", - "Programming Language :: Python", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", -] -dependencies = [ - "typing-extensions >=3.10", - "mypy-extensions >=0.4.3", - "anyio >=3", - "jsonpatch >=1.32", - "fastjsonschema >=2.14.5", - "requests >=2", - "colorlog >=6", - "asgiref >=3", - "lxml >=4", -] -[project.optional-dependencies] -all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] - -starlette = [ - "starlette >=0.13.6", - "uvicorn[standard] >=0.19.0", -] -sanic = [ - "sanic >=21", - "sanic-cors", - "uvicorn[standard] >=0.19.0", -] -fastapi = [ - "fastapi >=0.63.0", - "uvicorn[standard] >=0.19.0", -] -flask = [ - "flask", - "markupsafe>=1.1.1,<2.1", - "flask-cors", - "flask-sock", -] -tornado = [ - "tornado", -] -testing = [ - "playwright", -] - -[project.urls] -Source = "https://github.com/reactive-python/reactpy" -Documentation = "https://github.com/reactive-python/reactpy#readme" -Issues = "https://github.com/reactive-python/reactpy/discussions" - -# --- Hatch ---------------------------------------------------------------------------- - -[tool.hatch.version] -path = "reactpy/__init__.py" - -[tool.hatch.envs.default] -features = ["all"] -pre-install-command = "hatch build --hooks-only" -dependencies = [ - "coverage[toml]>=6.5", - "pytest", - "pytest-asyncio>=0.17", - "pytest-mock", - "pytest-rerunfailures", - "pytest-timeout", - "responses", - "playwright", - # I'm not quite sure why this needs to be installed for tests with Sanic to pass - "sanic-testing", - # Used to generate model changes from layout update messages - "jsonpointer", -] -[tool.hatch.envs.default.scripts] -test = "playwright install && pytest {args:tests}" -test-cov = "playwright install && coverage run -m pytest {args:tests}" -cov-report = [ - # "- coverage combine", - "coverage report", -] -cov = [ - "test-cov {args}", - "cov-report", -] - -[tool.hatch.envs.default.env-vars] -REACTPY_DEBUG_MODE="1" - -[tool.hatch.envs.lint] -features = ["all"] -dependencies = [ - "mypy>=1.0.0", - "types-click", - "types-tornado", - "types-pkg-resources", - "types-flask", - "types-requests", -] - -[tool.hatch.envs.lint.scripts] -types = "mypy --strict reactpy" -all = ["types"] - -[[tool.hatch.build.hooks.build-scripts.scripts]] -work_dir = "../../js" -out_dir = "reactpy/_static" -commands = [ - "npm ci", - "npm run build" -] -artifacts = [ - "app/dist/" -] - -# --- Pytest --------------------------------------------------------------------------- - -[tool.pytest.ini_options] -testpaths = "tests" -xfail_strict = true -python_files = "*asserts.py test_*.py" -asyncio_mode = "auto" - -# --- MyPy ----------------------------------------------------------------------------- - -[tool.mypy] -incremental = false -ignore_missing_imports = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true - -# --- Coverage ------------------------------------------------------------------------- - -[tool.coverage.run] -source_pkgs = ["reactpy"] -branch = false -parallel = false -omit = [ - "reactpy/__init__.py", -] - -[tool.coverage.report] -fail_under = 100 -show_missing = true -skip_covered = true -sort = "Name" -exclude_lines = [ - "no ?cov", - '\.\.\.', - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] -omit = [ - "reactpy/__main__.py", -] diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py deleted file mode 100644 index 996a984b2..000000000 --- a/src/py/reactpy/reactpy/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -from reactpy import backend, config, html, logging, sample, svg, types, web, widgets -from reactpy.backend.hooks import use_connection, use_location, use_scope -from reactpy.backend.utils import run -from reactpy.core import hooks -from reactpy.core.component import component -from reactpy.core.events import event -from reactpy.core.hooks import ( - create_context, - use_callback, - use_context, - use_debug_value, - use_effect, - use_memo, - use_reducer, - use_ref, - use_state, -) -from reactpy.core.layout import Layout -from reactpy.core.serve import Stop -from reactpy.core.vdom import vdom -from reactpy.utils import Ref, html_to_vdom, vdom_to_html - -__author__ = "The Reactive Python Team" -__version__ = "1.0.0" # DO NOT MODIFY - -__all__ = [ - "backend", - "component", - "config", - "create_context", - "event", - "hooks", - "html_to_vdom", - "html", - "Layout", - "logging", - "Ref", - "run", - "sample", - "Stop", - "svg", - "types", - "use_callback", - "use_connection", - "use_context", - "use_debug_value", - "use_effect", - "use_location", - "use_memo", - "use_reducer", - "use_ref", - "use_scope", - "use_state", - "vdom_to_html", - "vdom", - "web", - "widgets", -] diff --git a/src/py/reactpy/reactpy/__main__.py b/src/py/reactpy/reactpy/__main__.py deleted file mode 100644 index d70ddf684..000000000 --- a/src/py/reactpy/reactpy/__main__.py +++ /dev/null @@ -1,19 +0,0 @@ -import click - -import reactpy -from reactpy._console.rewrite_camel_case_props import rewrite_camel_case_props -from reactpy._console.rewrite_keys import rewrite_keys - - -@click.group() -@click.version_option(reactpy.__version__, prog_name=reactpy.__name__) -def app() -> None: - pass - - -app.add_command(rewrite_keys) -app.add_command(rewrite_camel_case_props) - - -if __name__ == "__main__": - app() diff --git a/src/py/reactpy/reactpy/_console/ast_utils.py b/src/py/reactpy/reactpy/_console/ast_utils.py deleted file mode 100644 index 220751119..000000000 --- a/src/py/reactpy/reactpy/_console/ast_utils.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -import ast -from collections.abc import Iterator, Sequence -from dataclasses import dataclass -from pathlib import Path -from textwrap import indent -from tokenize import COMMENT as COMMENT_TOKEN -from tokenize import generate_tokens -from typing import Any - -import click - -from reactpy import html - - -def rewrite_changed_nodes( - file: Path, - source: str, - tree: ast.AST, - changed: list[ChangedNode], -) -> str: - ast.fix_missing_locations(tree) - - lines = source.split("\n") - - # find closest parent nodes that should be re-written - nodes_to_unparse: list[ast.AST] = [] - for change in changed: - node_lineage = [change.node, *change.parents] - for i in range(len(node_lineage) - 1): - current_node, next_node = node_lineage[i : i + 2] - if ( - not hasattr(next_node, "lineno") - or next_node.lineno < change.node.lineno - or isinstance(next_node, (ast.ClassDef, ast.FunctionDef)) - ): - nodes_to_unparse.append(current_node) - break - else: # nocov - msg = "Failed to change code" - raise RuntimeError(msg) - - # check if an nodes to rewrite contain each other, pick outermost nodes - current_outermost_node, *sorted_nodes_to_unparse = sorted( - nodes_to_unparse, key=lambda n: n.lineno - ) - outermost_nodes_to_unparse = [current_outermost_node] - for node in sorted_nodes_to_unparse: - if ( - not current_outermost_node.end_lineno - or node.lineno > current_outermost_node.end_lineno - ): - current_outermost_node = node - outermost_nodes_to_unparse.append(node) - - moved_comment_lines_from_end: list[int] = [] - # now actually rewrite these nodes (in reverse to avoid changes earlier in file) - for node in reversed(outermost_nodes_to_unparse): - # make a best effort to preserve any comments that we're going to overwrite - comments = _find_comments(lines[node.lineno - 1 : node.end_lineno]) - - # there may be some content just before and after the content we're re-writing - before_replacement = lines[node.lineno - 1][: node.col_offset].lstrip() - - after_replacement = ( - lines[node.end_lineno - 1][node.end_col_offset :].strip() - if node.end_lineno is not None and node.end_col_offset is not None - else "" - ) - - replacement = indent( - before_replacement - + "\n".join([*comments, ast.unparse(node)]) - + after_replacement, - " " * (node.col_offset - len(before_replacement)), - ) - - lines[node.lineno - 1 : node.end_lineno or node.lineno] = [replacement] - - if comments: - moved_comment_lines_from_end.append(len(lines) - node.lineno) - - for lineno_from_end in sorted(set(moved_comment_lines_from_end)): - click.echo(f"Moved comments to {file}:{len(lines) - lineno_from_end}") - - return "\n".join(lines) - - -@dataclass -class ChangedNode: - node: ast.AST - parents: Sequence[ast.AST] - - -def find_element_constructor_usages( - tree: ast.AST, add_props: bool = False -) -> Iterator[ElementConstructorInfo]: - changed: list[Sequence[ast.AST]] = [] - for parents, node in _walk_with_parent(tree): - if not (isinstance(node, ast.Call)): - continue - - func = node.func - if isinstance(func, ast.Attribute) and ( - (isinstance(func.value, ast.Name) and func.value.id == "html") - or (isinstance(func.value, ast.Attribute) and func.value.attr == "html") - ): - name = func.attr - elif isinstance(func, ast.Name): - name = func.id - else: - continue - - maybe_attr_dict_node: Any | None = None - - if name == "vdom": - if len(node.args) == 0: - continue - elif len(node.args) == 1: - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - if add_props: - node.args.append(maybe_attr_dict_node) - else: - continue - elif isinstance(node.args[1], (ast.Constant, ast.JoinedStr)): - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - if add_props: - node.args.insert(1, maybe_attr_dict_node) - else: - continue - elif len(node.args) >= 2: # noqa: PLR2004 - maybe_attr_dict_node = node.args[1] - elif hasattr(html, name): - if len(node.args) == 0: - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - if add_props: - node.args.append(maybe_attr_dict_node) - else: - continue - elif isinstance(node.args[0], (ast.Constant, ast.JoinedStr)): - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - if add_props: - node.args.insert(0, maybe_attr_dict_node) - else: - continue - else: - maybe_attr_dict_node = node.args[0] - - if not maybe_attr_dict_node: - continue - - if isinstance(maybe_attr_dict_node, ast.Dict) or ( - isinstance(maybe_attr_dict_node, ast.Call) - and isinstance(maybe_attr_dict_node.func, ast.Name) - and maybe_attr_dict_node.func.id == "dict" - and isinstance(maybe_attr_dict_node.func.ctx, ast.Load) - ): - yield ElementConstructorInfo(node, maybe_attr_dict_node, parents) - - return changed - - -@dataclass -class ElementConstructorInfo: - call: ast.Call - props: ast.Dict | ast.Call - parents: Sequence[ast.AST] - - -def _find_comments(lines: list[str]) -> list[str]: - iter_lines = iter(lines) - return [ - token - for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines)) - if token_type == COMMENT_TOKEN - ] - - -def _walk_with_parent( - node: ast.AST, parents: tuple[ast.AST, ...] = () -) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]: - parents = (node, *parents) - for child in ast.iter_child_nodes(node): - yield parents, child - yield from _walk_with_parent(child, parents) diff --git a/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py b/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py deleted file mode 100644 index e5d1860c2..000000000 --- a/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import ast -import re -import sys -from copy import copy -from keyword import kwlist -from pathlib import Path -from typing import Callable - -import click - -from reactpy._console.ast_utils import ( - ChangedNode, - find_element_constructor_usages, - rewrite_changed_nodes, -) - -CAMEL_CASE_SUB_PATTERN = re.compile(r"(?<!^)(?=[A-Z])") - - -@click.command() -@click.argument("paths", nargs=-1, type=click.Path(exists=True)) -def rewrite_camel_case_props(paths: list[str]) -> None: - """Rewrite camelCase props to snake_case""" - if sys.version_info < (3, 9): # nocov - msg = "This command requires Python>=3.9" - raise RuntimeError(msg) - - for p in map(Path, paths): - for f in [p] if p.is_file() else p.rglob("*.py"): - result = generate_rewrite(file=f, source=f.read_text()) - if result is not None: - f.write_text(result) - - -def generate_rewrite(file: Path, source: str) -> str | None: - tree = ast.parse(source) - - changed = find_nodes_to_change(tree) - if not changed: - return None - - new = rewrite_changed_nodes(file, source, tree, changed) - return new - - -def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: - changed: list[ChangedNode] = [] - for el_info in find_element_constructor_usages(tree): - if _rewrite_props(el_info.props, _construct_prop_item): - changed.append(ChangedNode(el_info.call, el_info.parents)) - return changed - - -def conv_attr_name(name: str) -> str: - new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).lower() - return f"{new_name}_" if new_name in kwlist else new_name - - -def _construct_prop_item(key: str, value: ast.expr) -> tuple[str, ast.expr]: - if key == "style" and isinstance(value, (ast.Dict, ast.Call)): - new_value = copy(value) - if _rewrite_props( - new_value, - lambda k, v: ( - (k, v) - # avoid infinite recursion - if k == "style" - else _construct_prop_item(k, v) - ), - ): - value = new_value - else: - key = conv_attr_name(key) - return key, value - - -def _rewrite_props( - props_node: ast.Dict | ast.Call, - constructor: Callable[[str, ast.expr], tuple[str, ast.expr]], -) -> bool: - if isinstance(props_node, ast.Dict): - did_change = False - keys: list[ast.expr | None] = [] - values: list[ast.expr] = [] - for k, v in zip(props_node.keys, props_node.values): - if isinstance(k, ast.Constant) and isinstance(k.value, str): - k_value, new_v = constructor(k.value, v) - if k_value != k.value or new_v is not v: - did_change = True - k = ast.Constant(value=k_value) - v = new_v - keys.append(k) - values.append(v) - if not did_change: - return False - props_node.keys = keys - props_node.values = values - else: - did_change = False - keywords: list[ast.keyword] = [] - for kw in props_node.keywords: - if kw.arg is not None: - kw_arg, kw_value = constructor(kw.arg, kw.value) - if kw_arg != kw.arg or kw_value is not kw.value: - did_change = True - kw = ast.keyword(arg=kw_arg, value=kw_value) - keywords.append(kw) - if not did_change: - return False - props_node.keywords = keywords - return True diff --git a/src/py/reactpy/reactpy/_console/rewrite_keys.py b/src/py/reactpy/reactpy/_console/rewrite_keys.py deleted file mode 100644 index 64ed42f33..000000000 --- a/src/py/reactpy/reactpy/_console/rewrite_keys.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import ast -import sys -from pathlib import Path - -import click - -from reactpy import html -from reactpy._console.ast_utils import ( - ChangedNode, - find_element_constructor_usages, - rewrite_changed_nodes, -) - - -@click.command() -@click.argument("paths", nargs=-1, type=click.Path(exists=True)) -def rewrite_keys(paths: list[str]) -> None: - """Rewrite files under the given paths using the new html element API. - - The old API required users to pass a dictionary of attributes to html element - constructor functions. For example: - - >>> html.div({"className": "x"}, "y") - {"tagName": "div", "attributes": {"className": "x"}, "children": ["y"]} - - The latest API though allows for attributes to be passed as snake_cased keyword - arguments instead. The above example would be rewritten as: - - >>> html.div("y", class_name="x") - {"tagName": "div", "attributes": {"class_name": "x"}, "children": ["y"]} - - All snake_case attributes are converted to camelCase by the client where necessary. - - ----- Notes ----- - - While this command does it's best to preserve as much of the original code as - possible, there are inevitably some limitations in doing this. As a result, we - recommend running your code formatter like Black against your code after executing - this command. - - Additionally, We are unable to preserve the location of comments that lie within any - rewritten code. This command will place the comments in the code it plans to rewrite - just above its changes. As such it requires manual intervention to put those - comments back in their original location. - """ - if sys.version_info < (3, 9): # nocov - msg = "This command requires Python>=3.9" - raise RuntimeError(msg) - - for p in map(Path, paths): - for f in [p] if p.is_file() else p.rglob("*.py"): - result = generate_rewrite(file=f, source=f.read_text()) - if result is not None: - f.write_text(result) - - -def generate_rewrite(file: Path, source: str) -> str | None: - tree = ast.parse(source) - - changed = find_nodes_to_change(tree) - if not changed: - log_could_not_rewrite(file, tree) - return None - - new = rewrite_changed_nodes(file, source, tree, changed) - log_could_not_rewrite(file, ast.parse(new)) - - return new - - -def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: - changed: list[ChangedNode] = [] - for el_info in find_element_constructor_usages(tree, add_props=True): - for kw in list(el_info.call.keywords): - if kw.arg == "key": - break - else: - continue - - if isinstance(el_info.props, ast.Dict): - el_info.props.keys.append(ast.Constant("key")) - el_info.props.values.append(kw.value) - else: - el_info.props.keywords.append(ast.keyword(arg="key", value=kw.value)) - - el_info.call.keywords.remove(kw) - changed.append(ChangedNode(el_info.call, el_info.parents)) - - return changed - - -def log_could_not_rewrite(file: Path, tree: ast.AST) -> None: - for node in ast.walk(tree): - if not (isinstance(node, ast.Call) and node.keywords): - continue - - func = node.func - if isinstance(func, ast.Attribute): - name = func.attr - elif isinstance(func, ast.Name): - name = func.id - else: - continue - - if ( - name == "vdom" - or hasattr(html, name) - and any(kw.arg == "key" for kw in node.keywords) - ): - click.echo(f"Unable to rewrite usage at {file}:{node.lineno}") diff --git a/src/py/reactpy/reactpy/_option.py b/src/py/reactpy/reactpy/_option.py deleted file mode 100644 index 1421f33a3..000000000 --- a/src/py/reactpy/reactpy/_option.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import os -from logging import getLogger -from typing import Any, Callable, Generic, TypeVar, cast - -from reactpy._warnings import warn - -_O = TypeVar("_O") -logger = getLogger(__name__) - - -class Option(Generic[_O]): - """An option that can be set using an environment variable of the same name""" - - def __init__( - self, - name: str, - default: _O | Option[_O], - mutable: bool = True, - validator: Callable[[Any], _O] = lambda x: cast(_O, x), - ) -> None: - self._name = name - self._mutable = mutable - self._validator = validator - self._subscribers: list[Callable[[_O], None]] = [] - - if name in os.environ: - self._current = validator(os.environ[name]) - - self._default: _O - if isinstance(default, Option): - self._default = default.default - default.subscribe(lambda value: setattr(self, "_default", value)) - else: - self._default = default - - logger.debug(f"{self._name}={self.current}") - - @property - def name(self) -> str: - """The name of this option (used to load environment variables)""" - return self._name - - @property - def mutable(self) -> bool: - """Whether this option can be modified after being loaded""" - return self._mutable - - @property - def default(self) -> _O: - """This option's default value""" - return self._default - - @property - def current(self) -> _O: - try: - return self._current - except AttributeError: - return self._default - - @current.setter - def current(self, new: _O) -> None: - self.set_current(new) - - def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]: - """Register a callback that will be triggered when this option changes""" - if not self.mutable: - msg = "Immutable options cannot be subscribed to." - raise TypeError(msg) - self._subscribers.append(handler) - handler(self.current) - return handler - - def is_set(self) -> bool: - """Whether this option has a value other than its default.""" - return hasattr(self, "_current") - - def set_current(self, new: Any) -> None: - """Set the value of this option - - Raises a ``TypeError`` if this option is not :attr:`Option.mutable`. - """ - if not self._mutable: - msg = f"{self} cannot be modified after initial load" - raise TypeError(msg) - old = self.current - new = self._current = self._validator(new) - logger.debug(f"{self._name}={self._current}") - if new != old: - for sub_func in self._subscribers: - sub_func(new) - - def set_default(self, new: _O) -> _O: - """Set the value of this option if not :meth:`Option.is_set` - - Returns the current value (a la :meth:`dict.set_default`) - """ - if not self.is_set(): - self.set_current(new) - return self._current - - def reload(self) -> None: - """Reload this option from its environment variable""" - self.set_current(os.environ.get(self._name, self._default)) - - def unset(self) -> None: - """Remove the current value, the default will be used until it is set again.""" - if not self._mutable: - msg = f"{self} cannot be modified after initial load" - raise TypeError(msg) - old = self.current - delattr(self, "_current") - if self.current != old: - for sub_func in self._subscribers: - sub_func(self.current) - - def __repr__(self) -> str: - return f"Option({self._name}={self.current!r})" - - -class DeprecatedOption(Option[_O]): # nocov - def __init__(self, message: str, *args: Any, **kwargs: Any) -> None: - self._deprecation_message = message - super().__init__(*args, **kwargs) - - @Option.current.getter # type: ignore - def current(self) -> _O: - warn( - self._deprecation_message, - DeprecationWarning, - ) - return super().current diff --git a/src/py/reactpy/reactpy/_warnings.py b/src/py/reactpy/reactpy/_warnings.py deleted file mode 100644 index c4520604d..000000000 --- a/src/py/reactpy/reactpy/_warnings.py +++ /dev/null @@ -1,36 +0,0 @@ -from collections.abc import Iterator -from functools import wraps -from inspect import currentframe -from types import FrameType -from typing import TYPE_CHECKING, Any -from warnings import warn as _warn - - -@wraps(_warn) -def warn(*args: Any, **kwargs: Any) -> Any: - # warn at call site outside of ReactPy - _warn(*args, stacklevel=_frame_depth_in_module() + 1, **kwargs) # type: ignore - - -if TYPE_CHECKING: - warn = _warn # noqa: F811 - - -def _frame_depth_in_module() -> int: - depth = 0 - for frame in _iter_frames(2): - module_name = frame.f_globals.get("__name__") - if not module_name or not module_name.startswith("reactpy."): - break - depth += 1 - return depth - - -def _iter_frames(index: int = 1) -> Iterator[FrameType]: - frame = currentframe() - while frame is not None: - if index == 0: - yield frame - else: - index -= 1 - frame = frame.f_back diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py deleted file mode 100644 index 80b4eeee1..000000000 --- a/src/py/reactpy/reactpy/backend/_common.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from collections.abc import Awaitable, Sequence -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, cast - -from reactpy import __file__ as _reactpy_file_path -from reactpy import html -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.types import VdomDict -from reactpy.utils import vdom_to_html - -if TYPE_CHECKING: - from asgiref.typing import ASGIApplication - -PATH_PREFIX = PurePosixPath("/_reactpy") -MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" -STREAM_PATH = PATH_PREFIX / "stream" - -CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "_static" / "app" / "dist" - -try: - import uvicorn -except ImportError: # nocov - pass -else: - - async def serve_development_asgi( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, - ) -> None: - """Run a development server for an ASGI application""" - server = uvicorn.Server( - uvicorn.Config( - app, - host=host, - port=port, - loop="asyncio", - reload=True, - ) - ) - server.config.setup_event_loop() - coros: list[Awaitable[Any]] = [server.serve()] - - # If a started event is provided, then use it signal based on `server.started` - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's - # order of operations. So we need to make sure `shutdown()` always has an initialized - # list of `self.servers` to use. - if not hasattr(server, "servers"): # nocov - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) - - -async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() - - -def safe_client_build_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, - *("index.html" if path in ("", "/") else path).split("/"), - ) - - -def safe_web_modules_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) - - -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: - """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" - root = os.path.abspath(root) - - # Resolve relative paths but not symlinks - symlinks should be ok since their - # presence and where they point is under the control of the developer. - path = os.path.abspath(os.path.join(root, *unsafe)) - - if os.path.commonprefix([root, path]) != root: - # If the common prefix is not root directory we resolved outside the root dir - msg = "Unsafe path" - raise ValueError(msg) - - return Path(path) - - -def read_client_index_html(options: CommonOptions) -> str: - return ( - (CLIENT_BUILD_DIR / "index.html") - .read_text() - .format(__head__=vdom_head_elements_to_html(options.head)) - ) - - -def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str: - if isinstance(head, str): - return head - elif isinstance(head, dict): - if head.get("tagName") == "head": - head = cast(VdomDict, {**head, "tagName": ""}) - return vdom_to_html(head) - else: - return vdom_to_html(html._(head)) - - -@dataclass -class CommonOptions: - """Options for ReactPy's built-in backed server implementations""" - - head: Sequence[VdomDict] | VdomDict | str = ( - html.title("ReactPy"), - html.link( - { - "rel": "icon", - "href": "/_reactpy/assets/reactpy-logo.ico", - "type": "image/x-icon", - } - ), - ) - """Add elements to the ``<head>`` of the application. - - For example, this can be used to customize the title of the page, link extra - scripts, or load stylesheets. - """ - - url_prefix: str = "" - """The URL prefix where ReactPy resources will be served from""" - - def __post_init__(self) -> None: - if self.url_prefix and not self.url_prefix.startswith("/"): - msg = "Expected 'url_prefix' to start with '/'" - raise ValueError(msg) diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py deleted file mode 100644 index 4ca192c1c..000000000 --- a/src/py/reactpy/reactpy/backend/default.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import asyncio -from logging import getLogger -from sys import exc_info -from typing import Any, NoReturn - -from reactpy.backend.types import BackendImplementation -from reactpy.backend.utils import SUPPORTED_PACKAGES, all_implementations -from reactpy.types import RootComponentConstructor - -logger = getLogger(__name__) - - -def configure( - app: Any, component: RootComponentConstructor, options: None = None -) -> None: - """Configure the given app instance to display the given component""" - if options is not None: # nocov - msg = "Default implementation cannot be configured with options" - raise ValueError(msg) - return _default_implementation().configure(app, component) - - -def create_development_app() -> Any: - """Create an application instance for development purposes""" - return _default_implementation().create_development_app() - - -def Options(*args: Any, **kwargs: Any) -> NoReturn: # nocov - """Create configuration options""" - msg = "Default implementation has no options." - raise ValueError(msg) - - -async def serve_development_app( - app: Any, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run an application using a development server""" - return await _default_implementation().serve_development_app( - app, host, port, started - ) - - -_DEFAULT_IMPLEMENTATION: BackendImplementation[Any] | None = None - - -def _default_implementation() -> BackendImplementation[Any]: - """Get the first available server implementation""" - global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 - - if _DEFAULT_IMPLEMENTATION is not None: - return _DEFAULT_IMPLEMENTATION - - try: - implementation = next(all_implementations()) - except StopIteration: # nocov - logger.debug("Backend implementation import failed", exc_info=exc_info()) - supported_backends = ", ".join(SUPPORTED_PACKAGES) - msg = ( - "It seems you haven't installed a backend. To resolve this issue, " - "you can install a backend by running:\n\n" - '\033[1mpip install "reactpy[starlette]"\033[0m\n\n' - f"Other supported backends include: {supported_backends}." - ) - raise RuntimeError(msg) from None - else: - _DEFAULT_IMPLEMENTATION = implementation - return implementation diff --git a/src/py/reactpy/reactpy/backend/fastapi.py b/src/py/reactpy/reactpy/backend/fastapi.py deleted file mode 100644 index 575fce1fe..000000000 --- a/src/py/reactpy/reactpy/backend/fastapi.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from fastapi import FastAPI - -from reactpy.backend import starlette - -serve_development_app = starlette.serve_development_app -"""Alias for :func:`reactpy.backend.starlette.serve_development_app`""" - -use_connection = starlette.use_connection -"""Alias for :func:`reactpy.backend.starlette.use_location`""" - -use_websocket = starlette.use_websocket -"""Alias for :func:`reactpy.backend.starlette.use_websocket`""" - -Options = starlette.Options -"""Alias for :class:`reactpy.backend.starlette.Options`""" - -configure = starlette.configure -"""Alias for :class:`reactpy.backend.starlette.configure`""" - - -def create_development_app() -> FastAPI: - """Create a development ``FastAPI`` application instance.""" - return FastAPI(debug=True) diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py deleted file mode 100644 index 46aed3c46..000000000 --- a/src/py/reactpy/reactpy/backend/flask.py +++ /dev/null @@ -1,298 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -from asyncio import Queue as AsyncQueue -from dataclasses import dataclass -from queue import Queue as ThreadQueue -from threading import Event as ThreadEvent -from threading import Thread -from typing import Any, Callable, NamedTuple, NoReturn, cast - -from flask import ( - Blueprint, - Flask, - Request, - copy_current_request_context, - request, - send_file, -) -from flask_cors import CORS -from flask_sock import Sock -from simple_websocket import Server as WebSocket -from werkzeug.serving import BaseWSGIServer, make_server - -import reactpy -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, -) -from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.hooks import use_connection as _use_connection -from reactpy.backend.types import Connection, Location -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentType, RootComponentConstructor -from reactpy.utils import Ref - -logger = logging.getLogger(__name__) - - -def configure( - app: Flask, component: RootComponentConstructor, options: Options | None = None -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - api_bp = Blueprint(f"reactpy_api_{id(app)}", __name__, url_prefix=str(PATH_PREFIX)) - spa_bp = Blueprint( - f"reactpy_spa_{id(app)}", __name__, url_prefix=options.url_prefix - ) - - _setup_single_view_dispatcher_route(api_bp, options, component) - _setup_common_routes(api_bp, spa_bp, options) - - app.register_blueprint(api_bp) - app.register_blueprint(spa_bp) - - -def create_development_app() -> Flask: - """Create an application instance for development purposes""" - os.environ["FLASK_DEBUG"] = "true" - app = Flask(__name__) - return app - - -async def serve_development_app( - app: Flask, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run an application using a development server""" - loop = asyncio.get_running_loop() - stopped = asyncio.Event() - - server: Ref[BaseWSGIServer] = Ref() - - def run_server() -> None: - server.current = make_server(host, port, app, threaded=True) - if started: - loop.call_soon_threadsafe(started.set) - try: - server.current.serve_forever() # type: ignore - finally: - loop.call_soon_threadsafe(stopped.set) - - thread = Thread(target=run_server, daemon=True) - thread.start() - - if started: - await started.wait() - - try: - await stopped.wait() - finally: - # we may have exited because this task was cancelled - server.current.shutdown() - # the thread should eventually join - thread.join(timeout=3) - # just double check it happened - if thread.is_alive(): # nocov - msg = "Failed to shutdown server." - raise RuntimeError(msg) - - -def use_websocket() -> WebSocket: - """A handle to the current websocket""" - return use_connection().carrier.websocket - - -def use_request() -> Request: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_connection() -> Connection[_FlaskCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _FlaskCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.flask.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - @api_blueprint.route(f"/{ASSETS_PATH.name}/<path:path>") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) - - @api_blueprint.route(f"/{MODULES_PATH.name}/<path:path>") - def send_modules_dir(path: str = "") -> Any: - return send_file(safe_web_modules_dir_path(path)) - - index_html = read_client_index_html(options) - - @spa_blueprint.route("/") - @spa_blueprint.route("/<path:_>") - def send_client_dir(_: str = "") -> Any: - return index_html - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, options: Options, constructor: RootComponentConstructor -) -> None: - sock = Sock(api_blueprint) - - def model_stream(ws: WebSocket, path: str = "") -> None: - def send(value: Any) -> None: - ws.send(json.dumps(value)) - - def recv() -> Any: - return json.loads(ws.receive()) - - _dispatch_in_thread( - ws, - # remove any url prefix from path - path[len(options.url_prefix) :], - constructor(), - send, - recv, - ) - - sock.route(STREAM_PATH.name, endpoint="without_path")(model_stream) - sock.route(f"{STREAM_PATH.name}/<path:path>", endpoint="with_path")(model_stream) - - -def _dispatch_in_thread( - websocket: WebSocket, - path: str, - component: ComponentType, - send: Callable[[Any], None], - recv: Callable[[], Any | None], -) -> NoReturn: - dispatch_thread_info_created = ThreadEvent() - dispatch_thread_info_ref: reactpy.Ref[_DispatcherThreadInfo | None] = reactpy.Ref( - None - ) - - @copy_current_request_context - def run_dispatcher() -> None: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - thread_send_queue: ThreadQueue[Any] = ThreadQueue() - async_recv_queue: AsyncQueue[Any] = AsyncQueue() - - async def send_coro(value: Any) -> None: - thread_send_queue.put(value) - - async def main() -> None: - search = request.query_string.decode() - await serve_layout( - reactpy.Layout( - ConnectionContext( - component, - value=Connection( - scope=request.environ, - location=Location( - pathname=f"/{path}", - search=f"?{search}" if search else "", - ), - carrier=_FlaskCarrier(request, websocket), - ), - ), - ), - send_coro, - async_recv_queue.get, - ) - - main_future = asyncio.ensure_future(main(), loop=loop) - - dispatch_thread_info_ref.current = _DispatcherThreadInfo( - dispatch_loop=loop, - dispatch_future=main_future, - thread_send_queue=thread_send_queue, - async_recv_queue=async_recv_queue, - ) - dispatch_thread_info_created.set() - - loop.run_until_complete(main_future) - - Thread(target=run_dispatcher, daemon=True).start() - - dispatch_thread_info_created.wait() - dispatch_thread_info = cast(_DispatcherThreadInfo, dispatch_thread_info_ref.current) - - if dispatch_thread_info is None: - raise RuntimeError("Failed to create dispatcher thread") # nocov - - stop = ThreadEvent() - - def run_send() -> None: - while not stop.is_set(): - send(dispatch_thread_info.thread_send_queue.get()) - - Thread(target=run_send, daemon=True).start() - - try: - while True: - value = recv() - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.async_recv_queue.put_nowait, value - ) - finally: # nocov - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.dispatch_future.cancel - ) - - -class _DispatcherThreadInfo(NamedTuple): - dispatch_loop: asyncio.AbstractEventLoop - dispatch_future: asyncio.Future[Any] - thread_send_queue: ThreadQueue[Any] - async_recv_queue: AsyncQueue[Any] - - -@dataclass -class _FlaskCarrier: - """A simple wrapper for holding a Flask request and WebSocket""" - - request: Request - """The current request object""" - - websocket: WebSocket - """A handle to the current websocket""" diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py deleted file mode 100644 index 19ad114ed..000000000 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from collections.abc import MutableMapping -from typing import Any - -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import Context, create_context, use_context - -# backend implementations should establish this context at the root of an app -ConnectionContext: Context[Connection[Any] | None] = create_context(None) - - -def use_connection() -> Connection[Any]: - """Get the current :class:`~reactpy.backend.types.Connection`.""" - conn = use_context(ConnectionContext) - if conn is None: # nocov - msg = "No backend established a connection." - raise RuntimeError(msg) - return conn - - -def use_scope() -> MutableMapping[str, Any]: - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" - return use_connection().scope - - -def use_location() -> Location: - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" - return use_connection().location diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py deleted file mode 100644 index 53dd0ce68..000000000 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from dataclasses import dataclass -from typing import Any -from urllib import parse as urllib_parse -from uuid import uuid4 - -from sanic import Blueprint, Sanic, request, response -from sanic.config import Config -from sanic.server.websockets.connection import WebSocketConnection -from sanic_cors import CORS - -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, - serve_development_asgi, -) -from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.hooks import use_connection as _use_connection -from reactpy.backend.types import Connection, Location -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -def configure( - app: Sanic, component: RootComponentConstructor, options: Options | None = None -) -> None: - """Configure an application instance to display the given component""" - options = options or Options() - - spa_bp = Blueprint(f"reactpy_spa_{id(app)}", url_prefix=options.url_prefix) - api_bp = Blueprint(f"reactpy_api_{id(app)}", url_prefix=str(PATH_PREFIX)) - - _setup_common_routes(api_bp, spa_bp, options) - _setup_single_view_dispatcher_route(api_bp, component, options) - - app.blueprint([spa_bp, api_bp]) - - -def create_development_app() -> Sanic: - """Return a :class:`Sanic` app instance in test mode""" - Sanic.test_mode = True - logger.warning("Sanic.test_mode is now active") - app = Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) - return app - - -async def serve_development_app( - app: Sanic, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for :mod:`sanic`""" - await serve_development_asgi(app, host, port, started) - - -def use_request() -> request.Request: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_websocket() -> WebSocketConnection: - """Get the current websocket""" - return use_connection().carrier.websocket - - -def use_connection() -> Connection[_SanicCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _SanicCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Sanic server?" - raise TypeError(msg) - return conn - - -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.sanic.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``sanic_cors.CORS`` - """ - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - index_html = read_client_index_html(options) - - async def single_page_app_files( - request: request.Request, - _: str = "", - ) -> response.HTTPResponse: - return response.html(index_html) - - spa_blueprint.add_route( - single_page_app_files, - "/", - name="single_page_app_files_root", - ) - spa_blueprint.add_route( - single_page_app_files, - "/<_:path>", - name="single_page_app_files_path", - ) - - async def asset_files( - request: request.Request, - path: str = "", - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) - - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/<path:path>") - - async def web_module_files( - request: request.Request, - path: str, - _: str = "", # this is not used - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file( - safe_web_modules_dir_path(path), - mime_type="text/javascript", - ) - - api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/<path:path>") - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, - constructor: RootComponentConstructor, - options: Options, -) -> None: - async def model_stream( - request: request.Request, socket: WebSocketConnection, path: str = "" - ) -> None: - asgi_app = getattr(request.app, "_asgi_app", None) - scope = asgi_app.transport.scope if asgi_app else {} - if not scope: # nocov - logger.warning("No scope. Sanic may not be running with an ASGI server") - - send, recv = _make_send_recv_callbacks(socket) - await serve_layout( - Layout( - ConnectionContext( - constructor(), - value=Connection( - scope=scope, - location=Location( - pathname=f"/{path[len(options.url_prefix):]}", - search=( - f"?{request.query_string}" - if request.query_string - else "" - ), - ), - carrier=_SanicCarrier(request, socket), - ), - ) - ), - send, - recv, - ) - - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}", - name="model_stream_root", - ) - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}/<path:path>/", - name="model_stream_path", - ) - - -def _make_send_recv_callbacks( - socket: WebSocketConnection, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send(json.dumps(value)) - - async def sock_recv() -> Any: - data = await socket.recv() - if data is None: - raise Stop() - return json.loads(data) - - return sock_send, sock_recv - - -@dataclass -class _SanicCarrier: - """A simple wrapper for holding connection information""" - - request: request.Request - """The current request object""" - - websocket: WebSocketConnection - """A handle to the current websocket""" diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py deleted file mode 100644 index 3a9695b33..000000000 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Any, Callable - -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request -from starlette.responses import HTMLResponse -from starlette.staticfiles import StaticFiles -from starlette.websockets import WebSocket, WebSocketDisconnect - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, - serve_development_asgi, -) -from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.hooks import use_connection as _use_connection -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -def configure( - app: Starlette, - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(options, app, component) - - _setup_common_routes(options, app) - - -def create_development_app() -> Starlette: - """Return a :class:`Starlette` app instance in debug mode""" - return Starlette(debug=True) - - -async def serve_development_app( - app: Starlette, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for starlette""" - await serve_development_asgi(app, host, port, started) - - -def use_websocket() -> WebSocket: - """Get the current WebSocket object""" - return use_connection().carrier - - -def use_connection() -> Connection[WebSocket]: - conn = _use_connection() - if not isinstance(conn.carrier, WebSocket): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.starlette.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - -def _setup_common_routes(options: Options, app: Starlette) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = ( - cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} - ) - app.add_middleware(CORSMiddleware, **cors_params) - - # This really should be added to the APIRouter, but there's a bug in Starlette - # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = options.url_prefix - - app.mount( - str(MODULES_PATH), - StaticFiles(directory=REACTPY_WEB_MODULES_DIR.current, check_dir=False), - ) - app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), - ) - # register this last so it takes least priority - index_route = _make_index_route(options) - app.add_route(url_prefix + "/", index_route) - app.add_route(url_prefix + "/{path:path}", index_route) - - -def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: - index_html = read_client_index_html(options) - - async def serve_index(request: Request) -> HTMLResponse: - return HTMLResponse(index_html) - - return serve_index - - -def _setup_single_view_dispatcher_route( - options: Options, app: Starlette, component: RootComponentConstructor -) -> None: - @app.websocket_route(str(STREAM_PATH)) - @app.websocket_route(f"{STREAM_PATH}/{{path:path}}") - async def model_stream(socket: WebSocket) -> None: - await socket.accept() - send, recv = _make_send_recv_callbacks(socket) - - pathname = "/" + socket.scope["path_params"].get("path", "") - pathname = pathname[len(options.url_prefix) :] or "/" - search = socket.scope["query_string"].decode() - - try: - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=socket.scope, - location=Location(pathname, f"?{search}" if search else ""), - carrier=socket, - ), - ) - ), - send, - recv, - ) - except WebSocketDisconnect as error: - logger.info(f"WebSocket disconnect: {error.code}") - - -def _make_send_recv_callbacks( - socket: WebSocket, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send_text(json.dumps(value)) - - async def sock_recv() -> Any: - return json.loads(await socket.receive_text()) - - return sock_send, sock_recv diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py deleted file mode 100644 index 5ec877532..000000000 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from asyncio import Queue as AsyncQueue -from asyncio.futures import Future -from typing import Any -from urllib.parse import urljoin - -from tornado.httpserver import HTTPServer -from tornado.httputil import HTTPServerRequest -from tornado.log import enable_pretty_logging -from tornado.platform.asyncio import AsyncIOMainLoop -from tornado.web import Application, RequestHandler, StaticFileHandler -from tornado.websocket import WebSocketHandler -from tornado.wsgi import WSGIContainer -from typing_extensions import TypeAlias - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, -) -from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.hooks import use_connection as _use_connection -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentConstructor - -Options = CommonOptions -"""Render server config for :func:`reactpy.backend.tornado.configure`""" - - -def configure( - app: Application, - component: ComponentConstructor, - options: CommonOptions | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - _add_handler( - app, - options, - ( - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(component, options) - + _setup_common_routes(options) - ), - ) - - -def create_development_app() -> Application: - return Application(debug=True) - - -async def serve_development_app( - app: Application, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - enable_pretty_logging() - - AsyncIOMainLoop.current().install() - - server = HTTPServer(app) - server.listen(port, host) - - if started: - # at this point the server is accepting connection - started.set() - - try: - # block forever - tornado has already set up its own background tasks - await asyncio.get_running_loop().create_future() - finally: - # stop accepting new connections - server.stop() - # wait for existing connections to complete - await server.close_all_connections() - - -def use_request() -> HTTPServerRequest: - """Get the current ``HTTPServerRequest``""" - return use_connection().carrier - - -def use_connection() -> Connection[HTTPServerRequest]: - conn = _use_connection() - if not isinstance(conn.carrier, HTTPServerRequest): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -_RouteHandlerSpecs: TypeAlias = "list[tuple[str, type[RequestHandler], Any]]" - - -def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: - return [ - ( - rf"{MODULES_PATH}/(.*)", - StaticFileHandler, - {"path": str(REACTPY_WEB_MODULES_DIR.current)}, - ), - ( - rf"{ASSETS_PATH}/(.*)", - StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, - ), - ( - r"/(.*)", - IndexHandler, - {"index_html": read_client_index_html(options)}, - ), - ] - - -def _add_handler( - app: Application, options: Options, handlers: _RouteHandlerSpecs -) -> None: - prefixed_handlers: list[Any] = [ - (urljoin(options.url_prefix, route_pattern), *tuple(handler_info)) - for route_pattern, *handler_info in handlers - ] - app.add_handlers(r".*", prefixed_handlers) - - -def _setup_single_view_dispatcher_route( - constructor: ComponentConstructor, options: Options -) -> _RouteHandlerSpecs: - return [ - ( - rf"{STREAM_PATH}/(.*)", - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ( - str(STREAM_PATH), - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ] - - -class IndexHandler(RequestHandler): - _index_html: str - - def initialize(self, index_html: str) -> None: - self._index_html = index_html - - async def get(self, _: str) -> None: - self.finish(self._index_html) - - -class ModelStreamHandler(WebSocketHandler): - """A web-socket handler that serves up a new model stream to each new client""" - - _dispatch_future: Future[None] - _message_queue: AsyncQueue[str] - - def initialize( - self, component_constructor: ComponentConstructor, url_prefix: str - ) -> None: - self._component_constructor = component_constructor - self._url_prefix = url_prefix - - async def open(self, path: str = "", *args: Any, **kwargs: Any) -> None: - message_queue: AsyncQueue[str] = AsyncQueue() - - async def send(value: Any) -> None: - await self.write_message(json.dumps(value)) - - async def recv() -> Any: - return json.loads(await message_queue.get()) - - self._message_queue = message_queue - self._dispatch_future = asyncio.ensure_future( - serve_layout( - Layout( - ConnectionContext( - self._component_constructor(), - value=Connection( - scope=_FAKE_WSGI_CONTAINER.environ(self.request), - location=Location( - pathname=f"/{path[len(self._url_prefix):]}", - search=( - f"?{self.request.query}" - if self.request.query - else "" - ), - ), - carrier=self.request, - ), - ) - ), - send, - recv, - ) - ) - - async def on_message(self, message: str | bytes) -> None: - await self._message_queue.put( - message if isinstance(message, str) else message.decode() - ) - - def on_close(self) -> None: - if not self._dispatch_future.done(): - self._dispatch_future.cancel() - - -# The interface for WSGIContainer.environ changed in Tornado version 6.3 from -# a staticmethod to an instance method. Since we're not that concerned with -# the details of the WSGI app itself, we can just use a fake one. -# see: https://github.com/tornadoweb/tornado/pull/3231#issuecomment-1518957578 -_FAKE_WSGI_CONTAINER = WSGIContainer(lambda *a, **kw: iter([])) diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py deleted file mode 100644 index fbc4addc0..000000000 --- a/src/py/reactpy/reactpy/backend/types.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import MutableMapping -from dataclasses import dataclass -from typing import Any, Callable, Generic, Protocol, TypeVar, runtime_checkable - -from reactpy.core.types import RootComponentConstructor - -_App = TypeVar("_App") - - -@runtime_checkable -class BackendImplementation(Protocol[_App]): - """Common interface for built-in web server/framework integrations""" - - Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendImplementation.configure`""" - - def configure( - self, - app: _App, - component: RootComponentConstructor, - options: Any | None = None, - ) -> None: - """Configure the given app instance to display the given component""" - - def create_development_app(self) -> _App: - """Create an application instance for development purposes""" - - async def serve_development_app( - self, - app: _App, - host: str, - port: int, - started: asyncio.Event | None = None, - ) -> None: - """Run an application using a development server""" - - -_Carrier = TypeVar("_Carrier") - - -@dataclass -class Connection(Generic[_Carrier]): - """Represents a connection with a client""" - - scope: MutableMapping[str, Any] - """An ASGI scope or WSGI environment dictionary""" - - location: Location - """The current location (URL)""" - - carrier: _Carrier - """How the connection is mediated. For example, a request or websocket. - - This typically depends on the backend implementation. - """ - - -@dataclass -class Location: - """Represents the current location (URL) - - Analogous to, but not necessarily identical to, the client-side - ``document.location`` object. - """ - - pathname: str - """the path of the URL for the location""" - - search: str - """A search or query string - a '?' followed by the parameters of the URL. - - If there are no search parameters this should be an empty string - """ diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py deleted file mode 100644 index 3d9be13a4..000000000 --- a/src/py/reactpy/reactpy/backend/utils.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import socket -from collections.abc import Iterator -from contextlib import closing -from importlib import import_module -from typing import Any - -from reactpy.backend.types import BackendImplementation -from reactpy.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - -SUPPORTED_PACKAGES = ( - "starlette", - "fastapi", - "sanic", - "tornado", - "flask", -) - - -def run( - component: RootComponentConstructor, - host: str = "127.0.0.1", - port: int | None = None, - implementation: BackendImplementation[Any] | None = None, -) -> None: - """Run a component with a development server""" - logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) - - implementation = implementation or import_module("reactpy.backend.default") - - app = implementation.create_development_app() - implementation.configure(app, component) - - host = host - port = port or find_available_port(host) - - app_cls = type(app) - logger.info( - f"Running with {app_cls.__module__}.{app_cls.__name__} at http://{host}:{port}" - ) - - asyncio.run(implementation.serve_development_app(app, host, port)) - - -def find_available_port( - host: str, - port_min: int = 8000, - port_max: int = 9000, - allow_reuse_waiting_ports: bool = True, -) -> int: - """Get a port that's available for the given host and port range""" - for port in range(port_min, port_max): - with closing(socket.socket()) as sock: - try: - if allow_reuse_waiting_ports: - # As per this answer: https://stackoverflow.com/a/19247688/3159288 - # setting can be somewhat unreliable because we allow the use of - # ports that are stuck in TIME_WAIT. However, not setting the option - # means we're overly cautious and almost always use a different addr - # even if it could have actually been used. - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((host, port)) - except OSError: - pass - else: - return port - msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" - raise RuntimeError(msg) - - -def all_implementations() -> Iterator[BackendImplementation[Any]]: - """Yield all available server implementations""" - for name in SUPPORTED_PACKAGES: - try: - relative_import_name = f"{__name__.rsplit('.', 1)[0]}.{name}" - module = import_module(relative_import_name) - except ImportError: # nocov - logger.debug(f"Failed to import {name!r}", exc_info=True) - continue - - if not isinstance(module, BackendImplementation): # nocov - msg = f"{module.__name__!r} is an invalid implementation" - raise TypeError(msg) - - yield module - - -_DEVELOPMENT_RUN_FUNC_WARNING = f"""\ -The `run()` function is only intended for testing during development! To run in \ -production, consider selecting a supported backend and importing its associated \ -`configure()` function from `reactpy.backend.<package>` where `<package>` is one of \ -{list(SUPPORTED_PACKAGES)}. For details refer to the docs on how to run each package.\ -""" diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py deleted file mode 100644 index 6dc29096c..000000000 --- a/src/py/reactpy/reactpy/config.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -ReactPy provides a series of configuration options that can be set using environment -variables or, for those which allow it, a programmatic interface. -""" - -from pathlib import Path -from tempfile import TemporaryDirectory - -from reactpy._option import Option as _Option - -REACTPY_DEBUG_MODE = _Option( - "REACTPY_DEBUG_MODE", - default=False, - validator=lambda x: bool(int(x)), -) -"""This immutable option turns on/off debug mode - -The string values ``1`` and ``0`` are mapped to ``True`` and ``False`` respectively. - -When debug is on, extra validation measures are applied that negatively impact -performance but can be used to catch bugs during development. Additionally, the default -log level for ReactPy is set to ``DEBUG``. -""" - -REACTPY_CHECK_VDOM_SPEC = _Option( - "REACTPY_CHECK_VDOM_SPEC", - default=REACTPY_DEBUG_MODE, - validator=lambda x: bool(int(x)), -) -"""This immutable option turns on/off checks which ensure VDOM is rendered to spec - -The string values ``1`` and ``0`` are mapped to ``True`` and ``False`` respectively. - -By default this check is off. When ``REACTPY_DEBUG_MODE=1`` this will be turned on but can -be manually disablled by setting ``REACTPY_CHECK_VDOM_SPEC=0`` in addition. - -For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema` -""" - -# Because these web modules will be linked dynamically at runtime this can be temporary -_DEFAULT_WEB_MODULES_DIR = TemporaryDirectory() - -REACTPY_WEB_MODULES_DIR = _Option( - "REACTPY_WEB_MODULES_DIR", - default=Path(_DEFAULT_WEB_MODULES_DIR.name), - validator=Path, -) -"""The location ReactPy will use to store its client application - -This directory **MUST** be treated as a black box. Downstream applications **MUST NOT** -assume anything about the structure of this directory see :mod:`reactpy.web.module` for a -set of publicly available APIs for working with the client. -""" - -REACTPY_TESTING_DEFAULT_TIMEOUT = _Option( - "REACTPY_TESTING_DEFAULT_TIMEOUT", - 5.0, - mutable=False, - validator=float, -) -"""A default timeout for testing utilities in ReactPy""" diff --git a/src/py/reactpy/reactpy/core/_f_back.py b/src/py/reactpy/reactpy/core/_f_back.py deleted file mode 100644 index fe1a6c10c..000000000 --- a/src/py/reactpy/reactpy/core/_f_back.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -import inspect -from types import FrameType - - -def f_module_name(index: int = 0) -> str: - frame = f_back(index + 1) - if frame is None: - return "" # nocov - name = frame.f_globals.get("__name__", "") - if not isinstance(name, str): - raise TypeError("Expected module name to be a string") # nocov - return name - - -def f_back(index: int = 0) -> FrameType | None: - frame = inspect.currentframe() - while frame is not None: - if index < 0: - return frame - frame = frame.f_back - index -= 1 - return None # nocov diff --git a/src/py/reactpy/reactpy/core/_thread_local.py b/src/py/reactpy/reactpy/core/_thread_local.py deleted file mode 100644 index b3d6a14b0..000000000 --- a/src/py/reactpy/reactpy/core/_thread_local.py +++ /dev/null @@ -1,21 +0,0 @@ -from threading import Thread, current_thread -from typing import Callable, Generic, TypeVar -from weakref import WeakKeyDictionary - -_StateType = TypeVar("_StateType") - - -class ThreadLocal(Generic[_StateType]): - """Utility for managing per-thread state information""" - - def __init__(self, default: Callable[[], _StateType]): - self._default = default - self._state: WeakKeyDictionary[Thread, _StateType] = WeakKeyDictionary() - - def get(self) -> _StateType: - thread = current_thread() - if thread not in self._state: - state = self._state[thread] = self._default() - else: - state = self._state[thread] - return state diff --git a/src/py/reactpy/reactpy/core/component.py b/src/py/reactpy/reactpy/core/component.py deleted file mode 100644 index f825aac71..000000000 --- a/src/py/reactpy/reactpy/core/component.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import inspect -from functools import wraps -from typing import Any, Callable - -from reactpy.core.types import ComponentType, VdomDict - - -def component( - function: Callable[..., ComponentType | VdomDict | str | None] -) -> Callable[..., Component]: - """A decorator for defining a new component. - - Parameters: - function: The component's :meth:`reactpy.core.proto.ComponentType.render` function. - """ - sig = inspect.signature(function) - - if "key" in sig.parameters and sig.parameters["key"].kind in ( - inspect.Parameter.KEYWORD_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - msg = f"Component render function {function} uses reserved parameter 'key'" - raise TypeError(msg) - - @wraps(function) - def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: - return Component(function, key, args, kwargs, sig) - - return constructor - - -class Component: - """An object for rending component models.""" - - __slots__ = "__weakref__", "_func", "_args", "_kwargs", "_sig", "key", "type" - - def __init__( - self, - function: Callable[..., ComponentType | VdomDict | str | None], - key: Any | None, - args: tuple[Any, ...], - kwargs: dict[str, Any], - sig: inspect.Signature, - ) -> None: - self.key = key - self.type = function - self._args = args - self._kwargs = kwargs - self._sig = sig - - def render(self) -> ComponentType | VdomDict | str | None: - return self.type(*self._args, **self._kwargs) - - def __repr__(self) -> str: - try: - args = self._sig.bind(*self._args, **self._kwargs).arguments - except TypeError: - return f"{self.type.__name__}(...)" - else: - items = ", ".join(f"{k}={v!r}" for k, v in args.items()) - if items: - return f"{self.type.__name__}({id(self):02x}, {items})" - else: - return f"{self.type.__name__}({id(self):02x})" diff --git a/src/py/reactpy/reactpy/core/events.py b/src/py/reactpy/reactpy/core/events.py deleted file mode 100644 index acc2077b2..000000000 --- a/src/py/reactpy/reactpy/core/events.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Sequence -from typing import Any, Callable, Literal, overload - -from anyio import create_task_group - -from reactpy.core.types import EventHandlerFunc, EventHandlerType - - -@overload -def event( - function: Callable[..., Any], - *, - stop_propagation: bool = ..., - prevent_default: bool = ..., -) -> EventHandler: - ... - - -@overload -def event( - function: Literal[None] = None, - *, - stop_propagation: bool = ..., - prevent_default: bool = ..., -) -> Callable[[Callable[..., Any]], EventHandler]: - ... - - -def event( - function: Callable[..., Any] | None = None, - *, - stop_propagation: bool = False, - prevent_default: bool = False, -) -> EventHandler | Callable[[Callable[..., Any]], EventHandler]: - """A decorator for constructing an :class:`EventHandler`. - - While you're always free to add callbacks by assigning them to an element's attributes - - .. code-block:: python - - element = reactpy.html.button({"onClick": my_callback}) - - You may want the ability to prevent the default action associated with the event - from taking place, or stopping the event from propagating up the DOM. This decorator - allows you to add that functionality to your callbacks. - - .. code-block:: python - - @event(stop_propagation=True, prevent_default=True) - def my_callback(*data): - ... - - element = reactpy.html.button({"onClick": my_callback}) - - Parameters: - function: - A function or coroutine responsible for handling the event. - stop_propagation: - Block the event from propagating further up the DOM. - prevent_default: - Stops the default actional associate with the event from taking place. - """ - - def setup(function: Callable[..., Any]) -> EventHandler: - return EventHandler( - to_event_handler_function(function, positional_args=True), - stop_propagation, - prevent_default, - ) - - if function is not None: - return setup(function) - else: - return setup - - -class EventHandler: - """Turn a function or coroutine into an event handler - - Parameters: - function: - The function or coroutine which handles the event. - stop_propagation: - Block the event from propagating further up the DOM. - prevent_default: - Stops the default action associate with the event from taking place. - target: - A unique identifier for this event handler (auto-generated by default) - """ - - __slots__ = ( - "__weakref__", - "function", - "prevent_default", - "stop_propagation", - "target", - ) - - def __init__( - self, - function: EventHandlerFunc, - stop_propagation: bool = False, - prevent_default: bool = False, - target: str | None = None, - ) -> None: - self.function = to_event_handler_function(function, positional_args=False) - self.prevent_default = prevent_default - self.stop_propagation = stop_propagation - self.target = target - - def __eq__(self, other: Any) -> bool: - undefined = object() - for attr in ( - "function", - "prevent_default", - "stop_propagation", - "target", - ): - if not attr.startswith("_"): - if not getattr(other, attr, undefined) == getattr(self, attr): - return False - return True - - def __repr__(self) -> str: - public_names = [name for name in self.__slots__ if not name.startswith("_")] - items = ", ".join([f"{n}={getattr(self, n)!r}" for n in public_names]) - return f"{type(self).__name__}({items})" - - -def to_event_handler_function( - function: Callable[..., Any], - positional_args: bool = True, -) -> EventHandlerFunc: - """Make a :data:`~reactpy.core.proto.EventHandlerFunc` from a function or coroutine - - Parameters: - function: - A function or coroutine accepting a number of positional arguments. - positional_args: - Whether to pass the event parameters a positional args or as a list. - """ - if positional_args: - if asyncio.iscoroutinefunction(function): - - async def wrapper(data: Sequence[Any]) -> None: - await function(*data) - - else: - - async def wrapper(data: Sequence[Any]) -> None: - function(*data) - - return wrapper - elif not asyncio.iscoroutinefunction(function): - - async def wrapper(data: Sequence[Any]) -> None: - function(data) - - return wrapper - else: - return function - - -def merge_event_handlers( - event_handlers: Sequence[EventHandlerType], -) -> EventHandlerType: - """Merge multiple event handlers into one - - Raises a ValueError if any handlers have conflicting - :attr:`~reactpy.core.proto.EventHandlerType.stop_propagation` or - :attr:`~reactpy.core.proto.EventHandlerType.prevent_default` attributes. - """ - if not event_handlers: - msg = "No event handlers to merge" - raise ValueError(msg) - elif len(event_handlers) == 1: - return event_handlers[0] - - first_handler = event_handlers[0] - - stop_propagation = first_handler.stop_propagation - prevent_default = first_handler.prevent_default - target = first_handler.target - - for handler in event_handlers: - if ( - handler.stop_propagation != stop_propagation - or handler.prevent_default != prevent_default - or handler.target != target - ): - msg = "Cannot merge handlers - 'stop_propagation', 'prevent_default' or 'target' mismatch." - raise ValueError(msg) - - return EventHandler( - merge_event_handler_funcs([h.function for h in event_handlers]), - stop_propagation, - prevent_default, - target, - ) - - -def merge_event_handler_funcs( - functions: Sequence[EventHandlerFunc], -) -> EventHandlerFunc: - """Make one event handler function from many""" - if not functions: - msg = "No event handler functions to merge" - raise ValueError(msg) - elif len(functions) == 1: - return functions[0] - - async def await_all_event_handlers(data: Sequence[Any]) -> None: - async with create_task_group() as group: - for func in functions: - group.start_soon(func, data) - - return await_all_event_handlers diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py deleted file mode 100644 index a8334458b..000000000 --- a/src/py/reactpy/reactpy/core/hooks.py +++ /dev/null @@ -1,750 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Sequence -from logging import getLogger -from types import FunctionType -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generic, - NewType, - Protocol, - TypeVar, - cast, - overload, -) - -from typing_extensions import TypeAlias - -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Key, State, VdomDict -from reactpy.utils import Ref - -if not TYPE_CHECKING: - # make flake8 think that this variable exists - ellipsis = type(...) - - -__all__ = [ - "use_state", - "use_effect", - "use_reducer", - "use_callback", - "use_ref", - "use_memo", -] - -logger = getLogger(__name__) - -_Type = TypeVar("_Type") - - -@overload -def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: - ... - - -@overload -def use_state(initial_value: _Type) -> State[_Type]: - ... - - -def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: - """See the full :ref:`Use State` docs for details - - Parameters: - initial_value: - Defines the initial value of the state. A callable (accepting no arguments) - can be used as a constructor function to avoid re-creating the initial value - on each render. - - Returns: - A tuple containing the current state and a function to update it. - """ - current_state = _use_const(lambda: _CurrentState(initial_value)) - return State(current_state.value, current_state.dispatch) - - -class _CurrentState(Generic[_Type]): - __slots__ = "value", "dispatch" - - def __init__( - self, - initial_value: _Type | Callable[[], _Type], - ) -> None: - if callable(initial_value): - self.value = initial_value() - else: - self.value = initial_value - - hook = current_hook() - - def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: - if callable(new): - next_value = new(self.value) - else: - next_value = new - if not strictly_equal(next_value, self.value): - self.value = next_value - hook.schedule_render() - - self.dispatch = dispatch - - -_EffectCleanFunc: TypeAlias = "Callable[[], None]" -_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]" -_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" - - -@overload -def use_effect( - function: None = None, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None]: - ... - - -@overload -def use_effect( - function: _EffectApplyFunc, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> None: - ... - - -def use_effect( - function: _EffectApplyFunc | None = None, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None] | None: - """See the full :ref:`Use Effect` docs for details - - Parameters: - function: - Applies the effect and can return a clean-up function - dependencies: - Dependencies for the effect. The effect will only trigger if the identity - of any value in the given sequence changes (i.e. their :func:`id` is - different). By default these are inferred based on local variables that are - referenced by the given function. - - Returns: - If not function is provided, a decorator. Otherwise ``None``. - """ - hook = current_hook() - - dependencies = _try_to_infer_closure_values(function, dependencies) - memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) - - def add_effect(function: _EffectApplyFunc) -> None: - if not asyncio.iscoroutinefunction(function): - sync_function = cast(_SyncEffectFunc, function) - else: - async_function = cast(_AsyncEffectFunc, function) - - def sync_function() -> _EffectCleanFunc | None: - future = asyncio.ensure_future(async_function()) - - def clean_future() -> None: - if not future.cancel(): - clean = future.result() - if clean is not None: - clean() - - return clean_future - - def effect() -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() - - clean = last_clean_callback.current = sync_function() - if clean is not None: - hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean) - - return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)) - - if function is not None: - add_effect(function) - return None - else: - return add_effect - - -def use_debug_value( - message: Any | Callable[[], Any], - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> None: - """Log debug information when the given message changes. - - .. note:: - This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG_MODE` is active. - - Unlike other hooks, a message is considered to have changed if the old and new - values are ``!=``. Because this comparison is performed on every render of the - component, it may be worth considering the performance cost in some situations. - - Parameters: - message: - The value to log or a memoized function for generating the value. - dependencies: - Dependencies for the memoized function. The message will only be recomputed - if the identity of any value in the given sequence changes (i.e. their - :func:`id` is different). By default these are inferred based on local - variables that are referenced by the given function. - """ - old: Ref[Any] = _use_const(lambda: Ref(object())) - memo_func = message if callable(message) else lambda: message - new = use_memo(memo_func, dependencies) - - if REACTPY_DEBUG_MODE.current and old.current != new: - old.current = new - logger.debug(f"{current_hook().component} {new}") - - -def create_context(default_value: _Type) -> Context[_Type]: - """Return a new context type for use in :func:`use_context`""" - - def context( - *children: Any, - value: _Type = default_value, - key: Key | None = None, - ) -> ContextProvider[_Type]: - return ContextProvider( - *children, - value=value, - key=key, - type=context, - ) - - context.__qualname__ = "context" - - return context - - -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProvider[_Type]: - ... - - -def use_context(context: Context[_Type]) -> _Type: - """Get the current value for the given context type. - - See the full :ref:`Use Context` docs for more information. - """ - hook = current_hook() - provider = hook.get_context_provider(context) - - if provider is None: - # same assertions but with normal exceptions - if not isinstance(context, FunctionType): - raise TypeError(f"{context} is not a Context") # nocov - if context.__kwdefaults__ is None: - raise TypeError(f"{context} has no 'value' kwarg") # nocov - if "value" not in context.__kwdefaults__: - raise TypeError(f"{context} has no 'value' kwarg") # nocov - return cast(_Type, context.__kwdefaults__["value"]) - - return provider._value - - -class ContextProvider(Generic[_Type]): - def __init__( - self, - *children: Any, - value: _Type, - key: Key | None, - type: Context[_Type], - ) -> None: - self.children = children - self.key = key - self.type = type - self._value = value - - def render(self) -> VdomDict: - current_hook().set_context_provider(self) - return {"tagName": "", "children": self.children} - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.type})" - - -_ActionType = TypeVar("_ActionType") - - -def use_reducer( - reducer: Callable[[_Type, _ActionType], _Type], - initial_value: _Type, -) -> tuple[_Type, Callable[[_ActionType], None]]: - """See the full :ref:`Use Reducer` docs for details - - Parameters: - reducer: - A function which applies an action to the current state in order to - produce the next state. - initial_value: - The initial state value (same as for :func:`use_state`) - - Returns: - A tuple containing the current state and a function to change it with an action - """ - state, set_state = use_state(initial_value) - return state, _use_const(lambda: _create_dispatcher(reducer, set_state)) - - -def _create_dispatcher( - reducer: Callable[[_Type, _ActionType], _Type], - set_state: Callable[[Callable[[_Type], _Type]], None], -) -> Callable[[_ActionType], None]: - def dispatch(action: _ActionType) -> None: - set_state(lambda last_state: reducer(last_state, action)) - - return dispatch - - -_CallbackFunc = TypeVar("_CallbackFunc", bound=Callable[..., Any]) - - -@overload -def use_callback( - function: None = None, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_CallbackFunc], _CallbackFunc]: - ... - - -@overload -def use_callback( - function: _CallbackFunc, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _CallbackFunc: - ... - - -def use_callback( - function: _CallbackFunc | None = None, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _CallbackFunc | Callable[[_CallbackFunc], _CallbackFunc]: - """See the full :ref:`Use Callback` docs for details - - Parameters: - function: - The function whose identity will be preserved - dependencies: - Dependencies of the callback. The identity the ``function`` will be updated - if the identity of any value in the given sequence changes (i.e. their - :func:`id` is different). By default these are inferred based on local - variables that are referenced by the given function. - - Returns: - The current function - """ - dependencies = _try_to_infer_closure_values(function, dependencies) - memoize = use_memo(dependencies=dependencies) - - def setup(function: _CallbackFunc) -> _CallbackFunc: - return memoize(lambda: function) - - if function is not None: - return setup(function) - else: - return setup - - -class _LambdaCaller(Protocol): - """MyPy doesn't know how to deal with TypeVars only used in function return""" - - def __call__(self, func: Callable[[], _Type]) -> _Type: - ... - - -@overload -def use_memo( - function: None = None, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _LambdaCaller: - ... - - -@overload -def use_memo( - function: Callable[[], _Type], - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _Type: - ... - - -def use_memo( - function: Callable[[], _Type] | None = None, - dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _Type | Callable[[Callable[[], _Type]], _Type]: - """See the full :ref:`Use Memo` docs for details - - Parameters: - function: - The function to be memoized. - dependencies: - Dependencies for the memoized function. The memo will only be recomputed if - the identity of any value in the given sequence changes (i.e. their - :func:`id` is different). By default these are inferred based on local - variables that are referenced by the given function. - - Returns: - The current state - """ - dependencies = _try_to_infer_closure_values(function, dependencies) - - memo: _Memo[_Type] = _use_const(_Memo) - - if memo.empty(): - # we need to initialize on the first run - changed = True - memo.deps = () if dependencies is None else dependencies - elif dependencies is None: - changed = True - memo.deps = () - elif ( - len(memo.deps) != len(dependencies) - # if deps are same length check identity for each item - or not all( - strictly_equal(current, new) - for current, new in zip(memo.deps, dependencies) - ) - ): - memo.deps = dependencies - changed = True - else: - changed = False - - setup: Callable[[Callable[[], _Type]], _Type] - - if changed: - - def setup(function: Callable[[], _Type]) -> _Type: - current_value = memo.value = function() - return current_value - - else: - - def setup(function: Callable[[], _Type]) -> _Type: - return memo.value - - if function is not None: - return setup(function) - else: - return setup - - -class _Memo(Generic[_Type]): - """Simple object for storing memoization data""" - - __slots__ = "value", "deps" - - value: _Type - deps: Sequence[Any] - - def empty(self) -> bool: - try: - self.value # noqa: B018 - except AttributeError: - return True - else: - return False - - -def use_ref(initial_value: _Type) -> Ref[_Type]: - """See the full :ref:`Use State` docs for details - - Parameters: - initial_value: The value initially assigned to the reference. - - Returns: - A :class:`Ref` object. - """ - return _use_const(lambda: Ref(initial_value)) - - -def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function) - - -def _try_to_infer_closure_values( - func: Callable[..., Any] | None, - values: Sequence[Any] | ellipsis | None, -) -> Sequence[Any] | None: - if values is ...: - if isinstance(func, FunctionType): - return ( - [cell.cell_contents for cell in func.__closure__] - if func.__closure__ - else [] - ) - else: - return None - else: - return values - - -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _hook_stack.get() - if not hook_stack: - msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) - return hook_stack[-1] - - -_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) - - -EffectType = NewType("EffectType", str) -"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved""" - -COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER") -"""An effect that will be triggered each time a component renders""" - -LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER") -"""An effect that will be triggered each time a layout renders""" - -COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT") -"""An effect that will be triggered just before the component is unmounted""" - - -class LifeCycleHook: - """Defines the life cycle of a layout component. - - Components can request access to their own life cycle events and state through hooks - while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle - forward by triggering events and rendering view changes. - - Example: - - If removed from the complexities of a layout, a very simplified full life cycle - for a single component with no child components would look a bit like this: - - .. testcode:: - - from reactpy.core.hooks import ( - current_hook, - LifeCycleHook, - COMPONENT_DID_RENDER_EFFECT, - ) - - - # this function will come from a layout implementation - schedule_render = lambda: ... - - # --- start life cycle --- - - hook = LifeCycleHook(schedule_render) - - # --- start render cycle --- - - hook.affect_component_will_render(...) - - hook.set_current() - - try: - # render the component - ... - - # the component may access the current hook - assert current_hook() is hook - - # and save state or add effects - current_hook().use_state(lambda: ...) - current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...) - finally: - hook.unset_current() - - hook.affect_component_did_render() - - # This should only be called after the full set of changes associated with a - # given render have been completed. - hook.affect_layout_did_render() - - # Typically an event occurs and a new render is scheduled, thus beginning - # the render cycle anew. - hook.schedule_render() - - - # --- end render cycle --- - - hook.affect_component_will_unmount() - del hook - - # --- end render cycle --- - """ - - __slots__ = ( - "__weakref__", - "_context_providers", - "_current_state_index", - "_event_effects", - "_is_rendering", - "_rendered_atleast_once", - "_schedule_render_callback", - "_schedule_render_later", - "_state", - "component", - ) - - component: ComponentType - - def __init__( - self, - schedule_render: Callable[[], None], - ) -> None: - self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} - self._schedule_render_callback = schedule_render - self._schedule_render_later = False - self._is_rendering = False - self._rendered_atleast_once = False - self._current_state_index = 0 - self._state: tuple[Any, ...] = () - self._event_effects: dict[EffectType, list[Callable[[], None]]] = { - COMPONENT_DID_RENDER_EFFECT: [], - LAYOUT_DID_RENDER_EFFECT: [], - COMPONENT_WILL_UNMOUNT_EFFECT: [], - } - - def schedule_render(self) -> None: - if self._is_rendering: - self._schedule_render_later = True - else: - self._schedule_render() - - def use_state(self, function: Callable[[], _Type]) -> _Type: - if not self._rendered_atleast_once: - # since we're not initialized yet we're just appending state - result = function() - self._state += (result,) - else: - # once finalized we iterate over each succesively used piece of state - result = self._state[self._current_state_index] - self._current_state_index += 1 - return result - - def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None: - """Trigger a function on the occurrence of the given effect type""" - self._event_effects[effect_type].append(function) - - def set_context_provider(self, provider: ContextProvider[Any]) -> None: - self._context_providers[provider.type] = provider - - def get_context_provider( - self, context: Context[_Type] - ) -> ContextProvider[_Type] | None: - return self._context_providers.get(context) - - def affect_component_will_render(self, component: ComponentType) -> None: - """The component is about to render""" - self.component = component - - self._is_rendering = True - self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear() - - def affect_component_did_render(self) -> None: - """The component completed a render""" - del self.component - - component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT] - for effect in component_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Component post-render effect {effect} failed") - component_did_render_effects.clear() - - self._is_rendering = False - self._rendered_atleast_once = True - self._current_state_index = 0 - - def affect_layout_did_render(self) -> None: - """The layout completed a render""" - layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT] - for effect in layout_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Layout post-render effect {effect} failed") - layout_did_render_effects.clear() - - if self._schedule_render_later: - self._schedule_render() - self._schedule_render_later = False - - def affect_component_will_unmount(self) -> None: - """The component is about to be removed from the layout""" - will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT] - for effect in will_unmount_effects: - try: - effect() - except Exception: - logger.exception(f"Pre-unmount effect {effect} failed") - will_unmount_effects.clear() - - def set_current(self) -> None: - """Set this hook as the active hook in this thread - - This method is called by a layout before entering the render method - of this hook's associated component. - """ - hook_stack = _hook_stack.get() - if hook_stack: - parent = hook_stack[-1] - self._context_providers.update(parent._context_providers) - hook_stack.append(self) - - def unset_current(self) -> None: - """Unset this hook as the active hook in this thread""" - if _hook_stack.get().pop() is not self: - raise RuntimeError("Hook stack is in an invalid state") # nocov - - def _schedule_render(self) -> None: - try: - self._schedule_render_callback() - except Exception: - logger.exception( - f"Failed to schedule render via {self._schedule_render_callback}" - ) - - -def strictly_equal(x: Any, y: Any) -> bool: - """Check if two values are identical or, for a limited set or types, equal. - - Only the following types are checked for equality rather than identity: - - - ``int`` - - ``float`` - - ``complex`` - - ``str`` - - ``bytes`` - - ``bytearray`` - - ``memoryview`` - """ - return x is y or (type(x) in _NUMERIC_TEXT_BINARY_TYPES and x == y) - - -_NUMERIC_TEXT_BINARY_TYPES = { - # numeric - int, - float, - complex, - # text - str, - # binary types - bytes, - bytearray, - memoryview, -} diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py deleted file mode 100644 index 7c24e5ef7..000000000 --- a/src/py/reactpy/reactpy/core/layout.py +++ /dev/null @@ -1,698 +0,0 @@ -from __future__ import annotations - -import abc -import asyncio -from collections import Counter -from collections.abc import Iterator -from contextlib import ExitStack -from logging import getLogger -from typing import ( - Any, - Callable, - Generic, - NamedTuple, - NewType, - TypeVar, - cast, -) -from uuid import uuid4 -from weakref import ref as weakref - -from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE -from reactpy.core.hooks import LifeCycleHook -from reactpy.core.types import ( - ComponentType, - EventHandlerDict, - LayoutEventMessage, - LayoutUpdateMessage, - VdomDict, - VdomJson, -) -from reactpy.core.vdom import validate_vdom_json -from reactpy.utils import Ref - -logger = getLogger(__name__) - - -class Layout: - """Responsible for "rendering" components. That is, turning them into VDOM.""" - - __slots__ = [ - "root", - "_event_handlers", - "_rendering_queue", - "_root_life_cycle_state_id", - "_model_states_by_life_cycle_state_id", - ] - - if not hasattr(abc.ABC, "__weakref__"): # nocov - __slots__.append("__weakref__") - - def __init__(self, root: ComponentType) -> None: - super().__init__() - if not isinstance(root, ComponentType): - msg = f"Expected a ComponentType, not {type(root)!r}." - raise TypeError(msg) - self.root = root - - async def __aenter__(self) -> Layout: - # create attributes here to avoid access before entering context manager - self._event_handlers: EventHandlerDict = {} - - self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() - root_model_state = _new_root_model_state(self.root, self._rendering_queue.put) - - self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id - self._rendering_queue.put(root_id) - - self._model_states_by_life_cycle_state_id = {root_id: root_model_state} - - return self - - async def __aexit__(self, *exc: Any) -> None: - root_csid = self._root_life_cycle_state_id - root_model_state = self._model_states_by_life_cycle_state_id[root_csid] - self._unmount_model_states([root_model_state]) - - # delete attributes here to avoid access after exiting context manager - del self._event_handlers - del self._rendering_queue - del self._root_life_cycle_state_id - del self._model_states_by_life_cycle_state_id - - async def deliver(self, event: LayoutEventMessage) -> None: - """Dispatch an event to the targeted handler""" - # It is possible for an element in the frontend to produce an event - # associated with a backend model that has been deleted. We only handle - # events if the element and the handler exist in the backend. Otherwise - # we just ignore the event. - handler = self._event_handlers.get(event["target"]) - - if handler is not None: - try: - await handler.function(event["data"]) - except Exception: - logger.exception(f"Failed to execute event handler {handler}") - else: - logger.info( - f"Ignored event - handler {event['target']!r} " - "does not exist or its component unmounted" - ) - - async def render(self) -> LayoutUpdateMessage: - """Await the next available render. This will block until a component is updated""" - while True: - model_state_id = await self._rendering_queue.get() - try: - model_state = self._model_states_by_life_cycle_state_id[model_state_id] - except KeyError: - logger.debug( - "Did not render component with model state ID " - f"{model_state_id!r} - component already unmounted" - ) - else: - update = self._create_layout_update(model_state) - if REACTPY_CHECK_VDOM_SPEC.current: - root_id = self._root_life_cycle_state_id - root_model = self._model_states_by_life_cycle_state_id[root_id] - validate_vdom_json(root_model.model.current) - return update - - def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: - new_state = _copy_component_model_state(old_state) - component = new_state.life_cycle_state.component - - with ExitStack() as exit_stack: - self._render_component(exit_stack, old_state, new_state, component) - - return { - "type": "layout-update", - "path": new_state.patch_path, - "model": new_state.model.current, - } - - def _render_component( - self, - exit_stack: ExitStack, - old_state: _ModelState | None, - new_state: _ModelState, - component: ComponentType, - ) -> None: - life_cycle_state = new_state.life_cycle_state - life_cycle_hook = life_cycle_state.hook - - self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state - - life_cycle_hook.affect_component_will_render(component) - exit_stack.callback(life_cycle_hook.affect_layout_did_render) - life_cycle_hook.set_current() - try: - raw_model = component.render() - # wrap the model in a fragment (i.e. tagName="") to ensure components have - # a separate node in the model state tree. This could be removed if this - # components are given a node in the tree some other way - wrapper_model: VdomDict = {"tagName": ""} - if raw_model is not None: - wrapper_model["children"] = [raw_model] - self._render_model(exit_stack, old_state, new_state, wrapper_model) - except Exception as error: - logger.exception(f"Failed to render {component}") - new_state.model.current = { - "tagName": "", - "error": ( - f"{type(error).__name__}: {error}" - if REACTPY_DEBUG_MODE.current - else "" - ), - } - finally: - life_cycle_hook.unset_current() - life_cycle_hook.affect_component_did_render() - - try: - parent = new_state.parent - except AttributeError: - pass # only happens for root component - else: - key, index = new_state.key, new_state.index - parent.children_by_key[key] = new_state - # need to add this model to parent's children without mutating parent model - old_parent_model = parent.model.current - old_parent_children = old_parent_model["children"] - parent.model.current = { - **old_parent_model, # type: ignore[misc] - "children": [ - *old_parent_children[:index], - new_state.model.current, - *old_parent_children[index + 1 :], - ], - } - - def _render_model( - self, - exit_stack: ExitStack, - old_state: _ModelState | None, - new_state: _ModelState, - raw_model: Any, - ) -> None: - try: - new_state.model.current = {"tagName": raw_model["tagName"]} - except Exception as e: # nocov - msg = f"Expected a VDOM element dict, not {raw_model}" - raise ValueError(msg) from e - if "key" in raw_model: - new_state.key = new_state.model.current["key"] = raw_model["key"] - if "importSource" in raw_model: - new_state.model.current["importSource"] = raw_model["importSource"] - self._render_model_attributes(old_state, new_state, raw_model) - self._render_model_children( - exit_stack, old_state, new_state, raw_model.get("children", []) - ) - - def _render_model_attributes( - self, - old_state: _ModelState | None, - new_state: _ModelState, - raw_model: dict[str, Any], - ) -> None: - # extract event handlers from 'eventHandlers' and 'attributes' - handlers_by_event: EventHandlerDict = raw_model.get("eventHandlers", {}) - - if "attributes" in raw_model: - attrs = raw_model["attributes"].copy() - new_state.model.current["attributes"] = attrs - - if old_state is None: - self._render_model_event_handlers_without_old_state( - new_state, handlers_by_event - ) - return None - - for old_event in set(old_state.targets_by_event).difference(handlers_by_event): - old_target = old_state.targets_by_event[old_event] - del self._event_handlers[old_target] - - if not handlers_by_event: - return None - - model_event_handlers = new_state.model.current["eventHandlers"] = {} - for event, handler in handlers_by_event.items(): - if event in old_state.targets_by_event: - target = old_state.targets_by_event[event] - else: - target = uuid4().hex if handler.target is None else handler.target - new_state.targets_by_event[event] = target - self._event_handlers[target] = handler - model_event_handlers[event] = { - "target": target, - "preventDefault": handler.prevent_default, - "stopPropagation": handler.stop_propagation, - } - - return None - - def _render_model_event_handlers_without_old_state( - self, - new_state: _ModelState, - handlers_by_event: EventHandlerDict, - ) -> None: - if not handlers_by_event: - return None - - model_event_handlers = new_state.model.current["eventHandlers"] = {} - for event, handler in handlers_by_event.items(): - target = uuid4().hex if handler.target is None else handler.target - new_state.targets_by_event[event] = target - self._event_handlers[target] = handler - model_event_handlers[event] = { - "target": target, - "preventDefault": handler.prevent_default, - "stopPropagation": handler.stop_propagation, - } - - return None - - def _render_model_children( - self, - exit_stack: ExitStack, - old_state: _ModelState | None, - new_state: _ModelState, - raw_children: Any, - ) -> None: - if not isinstance(raw_children, (list, tuple)): - raw_children = [raw_children] - - if old_state is None: - if raw_children: - self._render_model_children_without_old_state( - exit_stack, new_state, raw_children - ) - return None - elif not raw_children: - self._unmount_model_states(list(old_state.children_by_key.values())) - return None - - child_type_key_tuples = list(_process_child_type_and_key(raw_children)) - - new_keys = {item[2] for item in child_type_key_tuples} - if len(new_keys) != len(raw_children): - key_counter = Counter(item[2] for item in child_type_key_tuples) - duplicate_keys = [key for key, count in key_counter.items() if count > 1] - msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" - raise ValueError(msg) - - old_keys = set(old_state.children_by_key).difference(new_keys) - if old_keys: - self._unmount_model_states( - [old_state.children_by_key[key] for key in old_keys] - ) - - new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate(child_type_key_tuples): - old_child_state = old_state.children_by_key.get(key) - if child_type is _DICT_TYPE: - old_child_state = old_state.children_by_key.get(key) - if old_child_state is None: - new_child_state = _make_element_model_state( - new_state, - index, - key, - ) - elif old_child_state.is_component_state: - self._unmount_model_states([old_child_state]) - new_child_state = _make_element_model_state( - new_state, - index, - key, - ) - old_child_state = None - else: - new_child_state = _update_element_model_state( - old_child_state, - new_state, - index, - ) - self._render_model(exit_stack, old_child_state, new_child_state, child) - new_state.append_child(new_child_state.model.current) - new_state.children_by_key[key] = new_child_state - elif child_type is _COMPONENT_TYPE: - child = cast(ComponentType, child) - old_child_state = old_state.children_by_key.get(key) - if old_child_state is None: - new_child_state = _make_component_model_state( - new_state, - index, - key, - child, - self._rendering_queue.put, - ) - elif old_child_state.is_component_state and ( - old_child_state.life_cycle_state.component.type != child.type - ): - self._unmount_model_states([old_child_state]) - old_child_state = None - new_child_state = _make_component_model_state( - new_state, - index, - key, - child, - self._rendering_queue.put, - ) - else: - new_child_state = _update_component_model_state( - old_child_state, - new_state, - index, - child, - self._rendering_queue.put, - ) - self._render_component( - exit_stack, old_child_state, new_child_state, child - ) - else: - old_child_state = old_state.children_by_key.get(key) - if old_child_state is not None: - self._unmount_model_states([old_child_state]) - new_state.append_child(child) - - def _render_model_children_without_old_state( - self, - exit_stack: ExitStack, - new_state: _ModelState, - raw_children: list[Any], - ) -> None: - child_type_key_tuples = list(_process_child_type_and_key(raw_children)) - - new_keys = {item[2] for item in child_type_key_tuples} - if len(new_keys) != len(raw_children): - key_counter = Counter(item[2] for item in child_type_key_tuples) - duplicate_keys = [key for key, count in key_counter.items() if count > 1] - msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" - raise ValueError(msg) - - new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate(child_type_key_tuples): - if child_type is _DICT_TYPE: - child_state = _make_element_model_state(new_state, index, key) - self._render_model(exit_stack, None, child_state, child) - new_state.append_child(child_state.model.current) - new_state.children_by_key[key] = child_state - elif child_type is _COMPONENT_TYPE: - child_state = _make_component_model_state( - new_state, index, key, child, self._rendering_queue.put - ) - self._render_component(exit_stack, None, child_state, child) - else: - new_state.append_child(child) - - def _unmount_model_states(self, old_states: list[_ModelState]) -> None: - to_unmount = old_states[::-1] # unmount in reversed order of rendering - while to_unmount: - model_state = to_unmount.pop() - - for target in model_state.targets_by_event.values(): - del self._event_handlers[target] - - if model_state.is_component_state: - life_cycle_state = model_state.life_cycle_state - del self._model_states_by_life_cycle_state_id[life_cycle_state.id] - life_cycle_state.hook.affect_component_will_unmount() - - to_unmount.extend(model_state.children_by_key.values()) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.root})" - - -def _new_root_model_state( - component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None] -) -> _ModelState: - return _ModelState( - parent=None, - index=-1, - key=None, - model=Ref(), - patch_path="", - children_by_key={}, - targets_by_event={}, - life_cycle_state=_make_life_cycle_state(component, schedule_render), - ) - - -def _make_component_model_state( - parent: _ModelState, - index: int, - key: Any, - component: ComponentType, - schedule_render: Callable[[_LifeCycleStateId], None], -) -> _ModelState: - return _ModelState( - parent=parent, - index=index, - key=key, - model=Ref(), - patch_path=f"{parent.patch_path}/children/{index}", - children_by_key={}, - targets_by_event={}, - life_cycle_state=_make_life_cycle_state(component, schedule_render), - ) - - -def _copy_component_model_state(old_model_state: _ModelState) -> _ModelState: - # use try/except here because not having a parent is rare (only the root state) - try: - parent: _ModelState | None = old_model_state.parent - except AttributeError: - parent = None - - return _ModelState( - parent=parent, - index=old_model_state.index, - key=old_model_state.key, - model=Ref(), # does not copy the model - patch_path=old_model_state.patch_path, - children_by_key={}, - targets_by_event={}, - life_cycle_state=old_model_state.life_cycle_state, - ) - - -def _update_component_model_state( - old_model_state: _ModelState, - new_parent: _ModelState, - new_index: int, - new_component: ComponentType, - schedule_render: Callable[[_LifeCycleStateId], None], -) -> _ModelState: - return _ModelState( - parent=new_parent, - index=new_index, - key=old_model_state.key, - model=Ref(), # does not copy the model - patch_path=old_model_state.patch_path, - children_by_key={}, - targets_by_event={}, - life_cycle_state=( - _update_life_cycle_state(old_model_state.life_cycle_state, new_component) - if old_model_state.is_component_state - else _make_life_cycle_state(new_component, schedule_render) - ), - ) - - -def _make_element_model_state( - parent: _ModelState, - index: int, - key: Any, -) -> _ModelState: - return _ModelState( - parent=parent, - index=index, - key=key, - model=Ref(), - patch_path=f"{parent.patch_path}/children/{index}", - children_by_key={}, - targets_by_event={}, - ) - - -def _update_element_model_state( - old_model_state: _ModelState, - new_parent: _ModelState, - new_index: int, -) -> _ModelState: - return _ModelState( - parent=new_parent, - index=new_index, - key=old_model_state.key, - model=Ref(), # does not copy the model - patch_path=old_model_state.patch_path, - children_by_key={}, - targets_by_event={}, - ) - - -class _ModelState: - """State that is bound to a particular element within the layout""" - - __slots__ = ( - "__weakref__", - "_parent_ref", - "children_by_key", - "index", - "key", - "life_cycle_state", - "model", - "patch_path", - "targets_by_event", - ) - - def __init__( - self, - parent: _ModelState | None, - index: int, - key: Any, - model: Ref[VdomJson], - patch_path: str, - children_by_key: dict[str, _ModelState], - targets_by_event: dict[str, str], - life_cycle_state: _LifeCycleState | None = None, - ): - self.index = index - """The index of the element amongst its siblings""" - - self.key = key - """A key that uniquely identifies the element amongst its siblings""" - - self.model = model - """The actual model of the element""" - - self.patch_path = patch_path - """A "/" delimited path to the element within the greater layout""" - - self.children_by_key = children_by_key - """Child model states indexed by their unique keys""" - - self.targets_by_event = targets_by_event - """The element's event handler target strings indexed by their event name""" - - # === Conditionally Available Attributes === - # It's easier to conditionally assign than to force a null check on every usage - - if parent is not None: - self._parent_ref = weakref(parent) - """The parent model state""" - - if life_cycle_state is not None: - self.life_cycle_state = life_cycle_state - """The state for the element's component (if it has one)""" - - @property - def is_component_state(self) -> bool: - return hasattr(self, "life_cycle_state") - - @property - def parent(self) -> _ModelState: - parent = self._parent_ref() - if parent is None: - raise RuntimeError("detached model state") # nocov - return parent - - def append_child(self, child: Any) -> None: - self.model.current["children"].append(child) - - def __repr__(self) -> str: # nocov - return f"ModelState({ {s: getattr(self, s, None) for s in self.__slots__} })" - - -def _make_life_cycle_state( - component: ComponentType, - schedule_render: Callable[[_LifeCycleStateId], None], -) -> _LifeCycleState: - life_cycle_state_id = _LifeCycleStateId(uuid4().hex) - return _LifeCycleState( - life_cycle_state_id, - LifeCycleHook(lambda: schedule_render(life_cycle_state_id)), - component, - ) - - -def _update_life_cycle_state( - old_life_cycle_state: _LifeCycleState, - new_component: ComponentType, -) -> _LifeCycleState: - return _LifeCycleState( - old_life_cycle_state.id, - # the hook is preserved across renders because it holds the state - old_life_cycle_state.hook, - new_component, - ) - - -_LifeCycleStateId = NewType("_LifeCycleStateId", str) - - -class _LifeCycleState(NamedTuple): - """Component state for :class:`_ModelState`""" - - id: _LifeCycleStateId - """A unique identifier used in the :class:`~reactpy.core.hooks.LifeCycleHook` callback""" - - hook: LifeCycleHook - """The life cycle hook""" - - component: ComponentType - """The current component instance""" - - -_Type = TypeVar("_Type") - - -class _ThreadSafeQueue(Generic[_Type]): - __slots__ = "_loop", "_queue", "_pending" - - def __init__(self) -> None: - self._loop = asyncio.get_running_loop() - self._queue: asyncio.Queue[_Type] = asyncio.Queue() - self._pending: set[_Type] = set() - - def put(self, value: _Type) -> None: - if value not in self._pending: - self._pending.add(value) - self._loop.call_soon_threadsafe(self._queue.put_nowait, value) - - async def get(self) -> _Type: - while True: - value = await self._queue.get() - if value in self._pending: - break - self._pending.remove(value) - return value - - -def _process_child_type_and_key( - children: list[Any], -) -> Iterator[tuple[Any, _ElementType, Any]]: - for index, child in enumerate(children): - if isinstance(child, dict): - child_type = _DICT_TYPE - key = child.get("key") - elif isinstance(child, ComponentType): - child_type = _COMPONENT_TYPE - key = getattr(child, "key", None) - else: - child = f"{child}" - child_type = _STRING_TYPE - key = None - - if key is None: - key = index - - yield (child, child_type, key) - - -# used in _process_child_type_and_key -_ElementType = NewType("_ElementType", int) -_DICT_TYPE = _ElementType(1) -_COMPONENT_TYPE = _ElementType(2) -_STRING_TYPE = _ElementType(3) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py deleted file mode 100644 index 61a7e4ce6..000000000 --- a/src/py/reactpy/reactpy/core/serve.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable -from logging import getLogger -from typing import Callable - -from anyio import create_task_group -from anyio.abc import TaskGroup - -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage - -logger = getLogger(__name__) - - -SendCoroutine = Callable[[LayoutUpdateMessage], Awaitable[None]] -"""Send model patches given by a dispatcher""" - -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage]] -"""Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` - -The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. -""" - - -class Stop(BaseException): - """Stop serving changes and events - - Raising this error will tell dispatchers to gracefully exit. Typically this is - called by code running inside a layout to tell it to stop rendering. - """ - - -async def serve_layout( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - """Run a dispatch loop for a single view instance""" - async with layout: - try: - async with create_task_group() as task_group: - task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, task_group, layout, recv) - except Stop: - logger.info(f"Stopped serving {layout}") - - -async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine -) -> None: - while True: - await send(await layout.render()) - - -async def _single_incoming_loop( - task_group: TaskGroup, - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], - recv: RecvCoroutine, -) -> None: - while True: - # We need to fire and forget here so that we avoid waiting on the completion - # of this event handler before receiving and running the next one. - task_group.start_soon(layout.deliver, await recv()) diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py deleted file mode 100644 index 45f300f4f..000000000 --- a/src/py/reactpy/reactpy/core/types.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -import sys -from collections import namedtuple -from collections.abc import Mapping, Sequence -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generic, - Literal, - NamedTuple, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -from typing_extensions import TypeAlias, TypedDict - -_Type = TypeVar("_Type") - - -if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): - - class State(NamedTuple, Generic[_Type]): - value: _Type - set_value: Callable[[_Type | Callable[[_Type], _Type]], None] - -else: # nocov - State = namedtuple("State", ("value", "set_value")) - - -ComponentConstructor = Callable[..., "ComponentType"] -"""Simple function returning a new component""" - -RootComponentConstructor = Callable[[], "ComponentType"] -"""The root component should be constructed by a function accepting no arguments.""" - - -Key: TypeAlias = "str | int" - - -_OwnType = TypeVar("_OwnType") - - -@runtime_checkable -class ComponentType(Protocol): - """The expected interface for all component-like objects""" - - key: Key | None - """An identifier which is unique amongst a component's immediate siblings""" - - type: Any - """The function or class defining the behavior of this component - - This is used to see if two component instances share the same definition. - """ - - def render(self) -> VdomDict | ComponentType | str | None: - """Render the component's view model.""" - - -_Render = TypeVar("_Render", covariant=True) -_Event = TypeVar("_Event", contravariant=True) - - -@runtime_checkable -class LayoutType(Protocol[_Render, _Event]): - """Renders and delivers, updates to views and events to handlers, respectively""" - - async def render(self) -> _Render: - """Render an update to a view""" - - async def deliver(self, event: _Event) -> None: - """Relay an event to its respective handler""" - - async def __aenter__(self) -> LayoutType[_Render, _Event]: - """Prepare the layout for its first render""" - - async def __aexit__( - self, - exc_type: type[Exception], - exc_value: Exception, - traceback: TracebackType, - ) -> bool | None: - """Clean up the view after its final render""" - - -VdomAttributes = Mapping[str, Any] -"""Describes the attributes of a :class:`VdomDict`""" - -VdomChild: TypeAlias = "ComponentType | VdomDict | str" -"""A single child element of a :class:`VdomDict`""" - -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" -"""Describes a series of :class:`VdomChild` elements""" - - -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ - # recursive types are not allowed yet: - # https://github.com/python/mypy/issues/731 - ComponentType - | dict[str, Any] - | str - | Any - ] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict - - -class _VdomDictRequired(TypedDict, total=True): - tagName: str - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" - - -class ImportSourceDict(TypedDict): - source: str - fallback: Any - sourceType: str - unmountBeforeUpdate: bool - - -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: dict[str, Any] - eventHandlers: dict[str, _JsonEventTarget] - importSource: _JsonImportSource - - -class _RequiredVdomJson(TypedDict, total=True): - tagName: str - - -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): - target: str - preventDefault: bool - stopPropagation: bool - - -class _JsonImportSource(TypedDict): - source: str - fallback: Any - - -EventHandlerMapping = Mapping[str, "EventHandlerType"] -"""A generic mapping between event names to their handlers""" - -EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" -"""A dict mapping between event names to their handlers""" - - -class EventHandlerFunc(Protocol): - """A coroutine which can handle event data""" - - async def __call__(self, data: Sequence[Any]) -> None: - ... - - -@runtime_checkable -class EventHandlerType(Protocol): - """Defines a handler for some event""" - - prevent_default: bool - """Whether to block the event from propagating further up the DOM""" - - stop_propagation: bool - """Stops the default action associate with the event from taking place.""" - - function: EventHandlerFunc - """A coroutine which can respond to an event and its data""" - - target: str | None - """Typically left as ``None`` except when a static target is useful. - - When testing, it may be useful to specify a static target ID so events can be - triggered programmatically. - - .. note:: - - When ``None``, it is left to a :class:`LayoutType` to auto generate a unique ID. - """ - - -class VdomDictConstructor(Protocol): - """Standard function for constructing a :class:`VdomDict`""" - - @overload - def __call__(self, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: - ... - - @overload - def __call__(self, *children: VdomChildren) -> VdomDict: - ... - - @overload - def __call__( - self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict: - ... - - -class LayoutUpdateMessage(TypedDict): - """A message describing an update to a layout""" - - type: Literal["layout-update"] - """The type of message""" - path: str - """JSON Pointer path to the model element being updated""" - model: VdomJson - """The model to assign at the given JSON Pointer path""" - - -class LayoutEventMessage(TypedDict): - """Message describing an event originating from an element in the layout""" - - type: Literal["layout-event"] - """The type of message""" - target: str - """The ID of the event handler.""" - data: Sequence[Any] - """A list of event data passed to the event handler.""" diff --git a/src/py/reactpy/reactpy/core/vdom.py b/src/py/reactpy/reactpy/core/vdom.py deleted file mode 100644 index 0548c6afc..000000000 --- a/src/py/reactpy/reactpy/core/vdom.py +++ /dev/null @@ -1,355 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Mapping, Sequence -from functools import wraps -from typing import Any, Protocol, cast, overload - -from fastjsonschema import compile as compile_json_schema - -from reactpy._warnings import warn -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._f_back import f_module_name -from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.types import ( - ComponentType, - EventHandlerDict, - EventHandlerType, - ImportSourceDict, - Key, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomDictConstructor, - VdomJson, -) - -logger = logging.getLogger() - - -VDOM_JSON_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema", - "$ref": "#/definitions/element", - "definitions": { - "element": { - "type": "object", - "properties": { - "tagName": {"type": "string"}, - "key": {"type": ["string", "number", "null"]}, - "error": {"type": "string"}, - "children": {"$ref": "#/definitions/elementChildren"}, - "attributes": {"type": "object"}, - "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, - "importSource": {"$ref": "#/definitions/importSource"}, - }, - # The 'tagName' is required because its presence is a useful indicator of - # whether a dictionary describes a VDOM model or not. - "required": ["tagName"], - "dependentSchemas": { - # When 'error' is given, the 'tagName' should be empty. - "error": {"properties": {"tagName": {"maxLength": 0}}} - }, - }, - "elementChildren": { - "type": "array", - "items": {"$ref": "#/definitions/elementOrString"}, - }, - "elementEventHandlers": { - "type": "object", - "patternProperties": { - ".*": {"$ref": "#/definitions/eventHandler"}, - }, - }, - "eventHandler": { - "type": "object", - "properties": { - "target": {"type": "string"}, - "preventDefault": {"type": "boolean"}, - "stopPropagation": {"type": "boolean"}, - }, - "required": ["target"], - }, - "importSource": { - "type": "object", - "properties": { - "source": {"type": "string"}, - "sourceType": {"enum": ["URL", "NAME"]}, - "fallback": { - "type": ["object", "string", "null"], - "if": {"not": {"type": "null"}}, - "then": {"$ref": "#/definitions/elementOrString"}, - }, - "unmountBeforeUpdate": {"type": "boolean"}, - }, - "required": ["source"], - }, - "elementOrString": { - "type": ["object", "string"], - "if": {"type": "object"}, - "then": {"$ref": "#/definitions/element"}, - }, - }, -} -"""JSON Schema describing serialized VDOM - see :ref:`VDOM` for more info""" - - -# we can't add a docstring to this because Sphinx doesn't know how to find its source -_COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA) - - -def validate_vdom_json(value: Any) -> VdomJson: - """Validate serialized VDOM - see :attr:`VDOM_JSON_SCHEMA` for more info""" - _COMPILED_VDOM_VALIDATOR(value) - return cast(VdomJson, value) - - -def is_vdom(value: Any) -> bool: - """Return whether a value is a :class:`VdomDict` - - This employs a very simple heuristic - something is VDOM if: - - 1. It is a ``dict`` instance - 2. It contains the key ``"tagName"`` - 3. The value of the key ``"tagName"`` is a string - - .. note:: - - Performing an ``isinstance(value, VdomDict)`` check is too restrictive since the - user would be forced to import ``VdomDict`` every time they needed to declare a - VDOM element. Giving the user more flexibility, at the cost of this check's - accuracy, is worth it. - """ - return ( - isinstance(value, dict) - and "tagName" in value - and isinstance(value["tagName"], str) - ) - - -@overload -def vdom(tag: str, *children: VdomChildren) -> VdomDict: - ... - - -@overload -def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: - ... - - -def vdom( - tag: str, - *attributes_and_children: Any, - **kwargs: Any, -) -> VdomDict: - """A helper function for creating VDOM elements. - - Parameters: - tag: - The type of element (e.g. 'div', 'h1', 'img') - attributes_and_children: - An optional attribute mapping followed by any number of children or - iterables of children. The attribute mapping **must** precede the children, - or children which will be merged into their respective parts of the model. - key: - A string indicating the identity of a particular element. This is significant - to preserve event handlers across updates - without a key, a re-render would - cause these handlers to be deleted, but with a key, they would be redirected - to any newly defined handlers. - event_handlers: - Maps event types to coroutines that are responsible for handling those events. - import_source: - (subject to change) specifies javascript that, when evaluated returns a - React component. - """ - if kwargs: # nocov - if "key" in kwargs: - if attributes_and_children: - maybe_attributes, *children = attributes_and_children - if _is_attributes(maybe_attributes): - attributes_and_children = ( - {**maybe_attributes, "key": kwargs.pop("key")}, - *children, - ) - else: - attributes_and_children = ( - {"key": kwargs.pop("key")}, - maybe_attributes, - *children, - ) - else: - attributes_and_children = ({"key": kwargs.pop("key")},) - warn( - "An element's 'key' must be declared in an attribute dict instead " - "of as a keyword argument. This will error in a future version.", - DeprecationWarning, - ) - - if kwargs: - msg = f"Extra keyword arguments {kwargs}" - raise ValueError(msg) - - model: VdomDict = {"tagName": tag} - - if not attributes_and_children: - return model - - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - - if attributes: - model["attributes"] = attributes - - if children: - model["children"] = children - - if key is not None: - model["key"] = key - - if event_handlers: - model["eventHandlers"] = event_handlers - - return model - - -def make_vdom_constructor( - tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None -) -> VdomDictConstructor: - """Return a constructor for VDOM dictionaries with the given tag name. - - The resulting callable will have the same interface as :func:`vdom` but without its - first ``tag`` argument. - """ - - def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict: - model = vdom(tag, *attributes_and_children, **kwargs) - if not allow_children and "children" in model: - msg = f"{tag!r} nodes cannot have children." - raise TypeError(msg) - if import_source: - model["importSource"] = import_source - return model - - # replicate common function attributes - constructor.__name__ = tag - constructor.__doc__ = ( - "Return a new " - f"`<{tag}> <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/{tag}>`__ " - "element represented by a :class:`VdomDict`." - ) - - module_name = f_module_name(1) - if module_name: - constructor.__module__ = module_name - constructor.__qualname__ = f"{module_name}.{tag}" - - return cast(VdomDictConstructor, constructor) - - -def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor: - """Cast function to VdomDictConstructor""" - - @wraps(func) - def wrapper(*attributes_and_children: Any) -> VdomDict: - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - return func(attributes, children, key, event_handlers) - - return cast(VdomDictConstructor, wrapper) - - -def separate_attributes_and_children( - values: Sequence[Any], -) -> tuple[dict[str, Any], list[Any]]: - if not values: - return {}, [] - - attributes: dict[str, Any] - children_or_iterables: Sequence[Any] - if _is_attributes(values[0]): - attributes, *children_or_iterables = values - else: - attributes = {} - children_or_iterables = values - - children: list[Any] = [] - for child in children_or_iterables: - if _is_single_child(child): - children.append(child) - else: - children.extend(child) - - return attributes, children - - -def separate_attributes_and_event_handlers( - attributes: Mapping[str, Any] -) -> tuple[dict[str, Any], EventHandlerDict]: - separated_attributes = {} - separated_event_handlers: dict[str, EventHandlerType] = {} - - for k, v in attributes.items(): - handler: EventHandlerType - - if callable(v): - handler = EventHandler(to_event_handler_function(v)) - elif ( - # isinstance check on protocols is slow - use function attr pre-check as a - # quick filter before actually performing slow EventHandlerType type check - hasattr(v, "function") - and isinstance(v, EventHandlerType) - ): - handler = v - else: - separated_attributes[k] = v - continue - - separated_event_handlers[k] = handler - - return separated_attributes, dict(separated_event_handlers.items()) - - -def _is_attributes(value: Any) -> bool: - return isinstance(value, Mapping) and "tagName" not in value - - -def _is_single_child(value: Any) -> bool: - if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): - return True - if REACTPY_DEBUG_MODE.current: - _validate_child_key_integrity(value) - return False - - -def _validate_child_key_integrity(value: Any) -> None: - if hasattr(value, "__iter__") and not hasattr(value, "__len__"): - logger.error( - f"Did not verify key-path integrity of children in generator {value} " - "- pass a sequence (i.e. list of finite length) in order to verify" - ) - else: - for child in value: - if isinstance(child, ComponentType) and child.key is None: - logger.error(f"Key not specified for child in list {child}") - elif isinstance(child, Mapping) and "key" not in child: - # remove 'children' to reduce log spam - child_copy = {**child, "children": _EllipsisRepr()} - logger.error(f"Key not specified for child in list {child_copy}") - - -class _CustomVdomDictConstructor(Protocol): - def __call__( - self, - attributes: VdomAttributes, - children: Sequence[VdomChild], - key: Key | None, - event_handlers: EventHandlerDict, - ) -> VdomDict: - ... - - -class _EllipsisRepr: - def __repr__(self) -> str: - return "..." diff --git a/src/py/reactpy/reactpy/html.py b/src/py/reactpy/reactpy/html.py deleted file mode 100644 index 22d318639..000000000 --- a/src/py/reactpy/reactpy/html.py +++ /dev/null @@ -1,544 +0,0 @@ -""" - -**Fragment** - -- :func:`_` - -**Document metadata** - -- :func:`base` -- :func:`head` -- :func:`link` -- :func:`meta` -- :func:`style` -- :func:`title` - -**Content sectioning** - -- :func:`address` -- :func:`article` -- :func:`aside` -- :func:`footer` -- :func:`header` -- :func:`h1` -- :func:`h2` -- :func:`h3` -- :func:`h4` -- :func:`h5` -- :func:`h6` -- :func:`main` -- :func:`nav` -- :func:`section` - -**Text content** - -- :func:`blockquote` -- :func:`dd` -- :func:`div` -- :func:`dl` -- :func:`dt` -- :func:`figcaption` -- :func:`figure` -- :func:`hr` -- :func:`li` -- :func:`ol` -- :func:`p` -- :func:`pre` -- :func:`ul` - -**Inline text semantics** - -- :func:`a` -- :func:`abbr` -- :func:`b` -- :func:`bdi` -- :func:`bdo` -- :func:`br` -- :func:`cite` -- :func:`code` -- :func:`data` -- :func:`em` -- :func:`i` -- :func:`kbd` -- :func:`mark` -- :func:`q` -- :func:`rp` -- :func:`rt` -- :func:`ruby` -- :func:`s` -- :func:`samp` -- :func:`small` -- :func:`span` -- :func:`strong` -- :func:`sub` -- :func:`sup` -- :func:`time` -- :func:`u` -- :func:`var` -- :func:`wbr` - -**Image and video** - -- :func:`area` -- :func:`audio` -- :func:`img` -- :func:`map` -- :func:`track` -- :func:`video` - -**Embedded content** - -- :func:`embed` -- :func:`iframe` -- :func:`object` -- :func:`param` -- :func:`picture` -- :func:`portal` -- :func:`source` - -**SVG and MathML** - -- :func:`svg` -- :func:`math` - -**Scripting** - -- :func:`canvas` -- :func:`noscript` -- :func:`script` - -**Demarcating edits** - -- :func:`del_` -- :func:`ins` - -**Table content** - -- :func:`caption` -- :func:`col` -- :func:`colgroup` -- :func:`table` -- :func:`tbody` -- :func:`td` -- :func:`tfoot` -- :func:`th` -- :func:`thead` -- :func:`tr` - -**Forms** - -- :func:`button` -- :func:`fieldset` -- :func:`form` -- :func:`input` -- :func:`label` -- :func:`legend` -- :func:`meter` -- :func:`option` -- :func:`output` -- :func:`progress` -- :func:`select` -- :func:`textarea` - -**Interactive elements** - -- :func:`details` -- :func:`dialog` -- :func:`menu` -- :func:`menuitem` -- :func:`summary` - -**Web components** - -- :func:`slot` -- :func:`template` - -.. autofunction:: _ -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from reactpy.core.types import ( - EventHandlerDict, - Key, - VdomAttributes, - VdomChild, - VdomDict, -) -from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor - -__all__ = ( - "_", - "a", - "abbr", - "address", - "area", - "article", - "aside", - "audio", - "b", - "base", - "bdi", - "bdo", - "blockquote", - "br", - "button", - "canvas", - "caption", - "cite", - "code", - "col", - "colgroup", - "data", - "dd", - "del_", - "details", - "dialog", - "div", - "dl", - "dt", - "em", - "embed", - "fieldset", - "figcaption", - "figure", - "footer", - "form", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "head", - "header", - "hr", - "i", - "iframe", - "img", - "input", - "ins", - "kbd", - "label", - "legend", - "li", - "link", - "main", - "map", - "mark", - "math", - "menu", - "menuitem", - "meta", - "meter", - "nav", - "noscript", - "object", - "ol", - "option", - "output", - "p", - "param", - "picture", - "portal", - "pre", - "progress", - "q", - "rp", - "rt", - "ruby", - "s", - "samp", - "script", - "section", - "select", - "slot", - "small", - "source", - "span", - "strong", - "style", - "sub", - "summary", - "sup", - "svg", - "table", - "tbody", - "td", - "template", - "textarea", - "tfoot", - "th", - "thead", - "time", - "title", - "tr", - "track", - "u", - "ul", - "var", - "video", - "wbr", -) - - -def _fragment( - attributes: VdomAttributes, - children: Sequence[VdomChild], - key: Key | None, - event_handlers: EventHandlerDict, -) -> VdomDict: - """An HTML fragment - this element will not appear in the DOM""" - if attributes or event_handlers: - msg = "Fragments cannot have attributes besides 'key'" - raise TypeError(msg) - model: VdomDict = {"tagName": ""} - - if children: - model["children"] = children - - if key is not None: - model["key"] = key - - return model - - -# FIXME: https://github.com/PyCQA/pylint/issues/5784 -_ = custom_vdom_constructor(_fragment) - - -# Document metadata -base = make_vdom_constructor("base") -head = make_vdom_constructor("head") -link = make_vdom_constructor("link") -meta = make_vdom_constructor("meta") -style = make_vdom_constructor("style") -title = make_vdom_constructor("title") - -# Content sectioning -address = make_vdom_constructor("address") -article = make_vdom_constructor("article") -aside = make_vdom_constructor("aside") -footer = make_vdom_constructor("footer") -header = make_vdom_constructor("header") -h1 = make_vdom_constructor("h1") -h2 = make_vdom_constructor("h2") -h3 = make_vdom_constructor("h3") -h4 = make_vdom_constructor("h4") -h5 = make_vdom_constructor("h5") -h6 = make_vdom_constructor("h6") -main = make_vdom_constructor("main") -nav = make_vdom_constructor("nav") -section = make_vdom_constructor("section") - -# Text content -blockquote = make_vdom_constructor("blockquote") -dd = make_vdom_constructor("dd") -div = make_vdom_constructor("div") -dl = make_vdom_constructor("dl") -dt = make_vdom_constructor("dt") -figcaption = make_vdom_constructor("figcaption") -figure = make_vdom_constructor("figure") -hr = make_vdom_constructor("hr", allow_children=False) -li = make_vdom_constructor("li") -ol = make_vdom_constructor("ol") -p = make_vdom_constructor("p") -pre = make_vdom_constructor("pre") -ul = make_vdom_constructor("ul") - -# Inline text semantics -a = make_vdom_constructor("a") -abbr = make_vdom_constructor("abbr") -b = make_vdom_constructor("b") -bdi = make_vdom_constructor("bdi") -bdo = make_vdom_constructor("bdo") -br = make_vdom_constructor("br", allow_children=False) -cite = make_vdom_constructor("cite") -code = make_vdom_constructor("code") -data = make_vdom_constructor("data") -em = make_vdom_constructor("em") -i = make_vdom_constructor("i") -kbd = make_vdom_constructor("kbd") -mark = make_vdom_constructor("mark") -q = make_vdom_constructor("q") -rp = make_vdom_constructor("rp") -rt = make_vdom_constructor("rt") -ruby = make_vdom_constructor("ruby") -s = make_vdom_constructor("s") -samp = make_vdom_constructor("samp") -small = make_vdom_constructor("small") -span = make_vdom_constructor("span") -strong = make_vdom_constructor("strong") -sub = make_vdom_constructor("sub") -sup = make_vdom_constructor("sup") -time = make_vdom_constructor("time") -u = make_vdom_constructor("u") -var = make_vdom_constructor("var") -wbr = make_vdom_constructor("wbr") - -# Image and video -area = make_vdom_constructor("area", allow_children=False) -audio = make_vdom_constructor("audio") -img = make_vdom_constructor("img", allow_children=False) -map = make_vdom_constructor("map") # noqa: A001 -track = make_vdom_constructor("track") -video = make_vdom_constructor("video") - -# Embedded content -embed = make_vdom_constructor("embed", allow_children=False) -iframe = make_vdom_constructor("iframe", allow_children=False) -object = make_vdom_constructor("object") # noqa: A001 -param = make_vdom_constructor("param") -picture = make_vdom_constructor("picture") -portal = make_vdom_constructor("portal", allow_children=False) -source = make_vdom_constructor("source", allow_children=False) - -# SVG and MathML -svg = make_vdom_constructor("svg") -math = make_vdom_constructor("math") - -# Scripting -canvas = make_vdom_constructor("canvas") -noscript = make_vdom_constructor("noscript") - - -def _script( - attributes: VdomAttributes, - children: Sequence[VdomChild], - key: Key | None, - event_handlers: EventHandlerDict, -) -> VdomDict: - """Create a new `<script> <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script>`__ element. - - .. warning:: - - Be careful to sanitize data from untrusted sources before using it in a script. - See the "Notes" for more details - - This behaves slightly differently than a normal script element in that it may be run - multiple times if its key changes (depending on specific browser behaviors). If no - key is given, the key is inferred to be the content of the script or, lastly its - 'src' attribute if that is given. - - If no attributes are given, the content of the script may evaluate to a function. - This function will be called when the script is initially created or when the - content of the script changes. The function may itself optionally return a teardown - function that is called when the script element is removed from the tree, or when - the script content changes. - - Notes: - Do not use unsanitized data from untrusted sources anywhere in your script. - Doing so may allow for malicious code injection. Consider this **insecure** - code: - - .. code-block:: - - my_script = html.script(f"console.log('{user_bio}');") - - A clever attacker could construct ``user_bio`` such that they could escape the - string and execute arbitrary code to perform cross-site scripting - (`XSS <https://en.wikipedia.org/wiki/Cross-site_scripting>`__`). For example, - what if ``user_bio`` were of the form: - - .. code-block:: text - - '); attackerCodeHere(); (' - - This would allow the following Javascript code to be executed client-side: - - .. code-block:: js - - console.log(''); attackerCodeHere(); (''); - - One way to avoid this could be to escape ``user_bio`` so as to prevent the - injection of Javascript code. For example: - - .. code-block:: python - - import json - my_script = html.script(f"console.log({json.dumps(user_bio)});") - - This would prevent the injection of Javascript code by escaping the ``user_bio`` - string. In this case, the following client-side code would be executed instead: - - .. code-block:: js - - console.log("'); attackerCodeHere(); ('"); - - This is a very simple example, but it illustrates the point that you should - always be careful when using unsanitized data from untrusted sources. - """ - model: VdomDict = {"tagName": "script"} - - if event_handlers: - msg = "'script' elements do not support event handlers" - raise ValueError(msg) - - if children: - if len(children) > 1: - msg = "'script' nodes may have, at most, one child." - raise ValueError(msg) - elif not isinstance(children[0], str): - msg = "The child of a 'script' must be a string." - raise ValueError(msg) - else: - model["children"] = children - if key is None: - key = children[0] - - if attributes: - model["attributes"] = attributes - if key is None and not children and "src" in attributes: - key = attributes["src"] - - if key is not None: - model["key"] = key - - return model - - -# FIXME: https://github.com/PyCQA/pylint/issues/5784 -script = custom_vdom_constructor(_script) - -# Demarcating edits -del_ = make_vdom_constructor("del") -ins = make_vdom_constructor("ins") - -# Table content -caption = make_vdom_constructor("caption") -col = make_vdom_constructor("col") -colgroup = make_vdom_constructor("colgroup") -table = make_vdom_constructor("table") -tbody = make_vdom_constructor("tbody") -td = make_vdom_constructor("td") -tfoot = make_vdom_constructor("tfoot") -th = make_vdom_constructor("th") -thead = make_vdom_constructor("thead") -tr = make_vdom_constructor("tr") - -# Forms -button = make_vdom_constructor("button") -fieldset = make_vdom_constructor("fieldset") -form = make_vdom_constructor("form") -input = make_vdom_constructor("input", allow_children=False) # noqa: A001 -label = make_vdom_constructor("label") -legend = make_vdom_constructor("legend") -meter = make_vdom_constructor("meter") -option = make_vdom_constructor("option") -output = make_vdom_constructor("output") -progress = make_vdom_constructor("progress") -select = make_vdom_constructor("select") -textarea = make_vdom_constructor("textarea") - -# Interactive elements -details = make_vdom_constructor("details") -dialog = make_vdom_constructor("dialog") -menu = make_vdom_constructor("menu") -menuitem = make_vdom_constructor("menuitem") -summary = make_vdom_constructor("summary") - -# Web components -slot = make_vdom_constructor("slot") -template = make_vdom_constructor("template") diff --git a/src/py/reactpy/reactpy/logging.py b/src/py/reactpy/reactpy/logging.py deleted file mode 100644 index f10414cb6..000000000 --- a/src/py/reactpy/reactpy/logging.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -import sys -from logging.config import dictConfig - -from reactpy.config import REACTPY_DEBUG_MODE - -dictConfig( - { - "version": 1, - "disable_existing_loggers": False, - "loggers": { - "reactpy": {"handlers": ["console"]}, - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "formatter": "generic", - "stream": sys.stdout, - } - }, - "formatters": { - "generic": { - "format": "%(asctime)s | %(log_color)s%(levelname)s%(reset)s | %(message)s", - "datefmt": r"%Y-%m-%dT%H:%M:%S%z", - "class": "colorlog.ColoredFormatter", - } - }, - } -) - - -ROOT_LOGGER = logging.getLogger("reactpy") -"""ReactPy's root logger instance""" - - -@REACTPY_DEBUG_MODE.subscribe -def _set_debug_level(debug: bool) -> None: - if debug: - ROOT_LOGGER.setLevel("DEBUG") - ROOT_LOGGER.debug("ReactPy is in debug mode") - else: - ROOT_LOGGER.setLevel("INFO") diff --git a/src/py/reactpy/reactpy/py.typed b/src/py/reactpy/reactpy/py.typed deleted file mode 100644 index 7632ecf77..000000000 --- a/src/py/reactpy/reactpy/py.typed +++ /dev/null @@ -1 +0,0 @@ -# Marker file for PEP 561 diff --git a/src/py/reactpy/reactpy/sample.py b/src/py/reactpy/reactpy/sample.py deleted file mode 100644 index 8509c773d..000000000 --- a/src/py/reactpy/reactpy/sample.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from reactpy import html -from reactpy.core.component import component -from reactpy.core.types import VdomDict - - -@component -def SampleApp() -> VdomDict: - return html.div( - {"id": "sample", "style": {"padding": "15px"}}, - html.h1("Sample Application"), - html.p( - "This is a basic application made with ReactPy. Click ", - html.a( - {"href": "https://pypi.org/project/reactpy/", "target": "_blank"}, - "here", - ), - " to learn more.", - ), - ) diff --git a/src/py/reactpy/reactpy/svg.py b/src/py/reactpy/reactpy/svg.py deleted file mode 100644 index ebfe58ee6..000000000 --- a/src/py/reactpy/reactpy/svg.py +++ /dev/null @@ -1,139 +0,0 @@ -from reactpy.core.vdom import make_vdom_constructor - -__all__ = ( - "a", - "animate", - "animate_motion", - "animate_transform", - "circle", - "clip_path", - "defs", - "desc", - "discard", - "ellipse", - "fe_blend", - "fe_color_matrix", - "fe_component_transfer", - "fe_composite", - "fe_convolve_matrix", - "fe_diffuse_lighting", - "fe_displacement_map", - "fe_distant_light", - "fe_drop_shadow", - "fe_flood", - "fe_func_a", - "fe_func_b", - "fe_func_g", - "fe_func_r", - "fe_gaussian_blur", - "fe_image", - "fe_merge", - "fe_merge_node", - "fe_morphology", - "fe_offset", - "fe_point_light", - "fe_specular_lighting", - "fe_spot_light", - "fe_tile", - "fe_turbulence", - "filter", - "foreign_object", - "g", - "hatch", - "hatchpath", - "image", - "line", - "linear_gradient", - "marker", - "mask", - "metadata", - "mpath", - "path", - "pattern", - "polygon", - "polyline", - "radial_gradient", - "rect", - "script", - "set", - "stop", - "style", - "svg", - "switch", - "symbol", - "text", - "text_path", - "title", - "tspan", - "use", - "view", -) - -a = make_vdom_constructor("a") -animate = make_vdom_constructor("animate", allow_children=False) -animate_motion = make_vdom_constructor("animateMotion", allow_children=False) -animate_transform = make_vdom_constructor("animateTransform", allow_children=False) -circle = make_vdom_constructor("circle", allow_children=False) -clip_path = make_vdom_constructor("clipPath") -defs = make_vdom_constructor("defs") -desc = make_vdom_constructor("desc", allow_children=False) -discard = make_vdom_constructor("discard", allow_children=False) -ellipse = make_vdom_constructor("ellipse", allow_children=False) -fe_blend = make_vdom_constructor("feBlend", allow_children=False) -fe_color_matrix = make_vdom_constructor("feColorMatrix", allow_children=False) -fe_component_transfer = make_vdom_constructor( - "feComponentTransfer", allow_children=False -) -fe_composite = make_vdom_constructor("feComposite", allow_children=False) -fe_convolve_matrix = make_vdom_constructor("feConvolveMatrix", allow_children=False) -fe_diffuse_lighting = make_vdom_constructor("feDiffuseLighting", allow_children=False) -fe_displacement_map = make_vdom_constructor("feDisplacementMap", allow_children=False) -fe_distant_light = make_vdom_constructor("feDistantLight", allow_children=False) -fe_drop_shadow = make_vdom_constructor("feDropShadow", allow_children=False) -fe_flood = make_vdom_constructor("feFlood", allow_children=False) -fe_func_a = make_vdom_constructor("feFuncA", allow_children=False) -fe_func_b = make_vdom_constructor("feFuncB", allow_children=False) -fe_func_g = make_vdom_constructor("feFuncG", allow_children=False) -fe_func_r = make_vdom_constructor("feFuncR", allow_children=False) -fe_gaussian_blur = make_vdom_constructor("feGaussianBlur", allow_children=False) -fe_image = make_vdom_constructor("feImage", allow_children=False) -fe_merge = make_vdom_constructor("feMerge", allow_children=False) -fe_merge_node = make_vdom_constructor("feMergeNode", allow_children=False) -fe_morphology = make_vdom_constructor("feMorphology", allow_children=False) -fe_offset = make_vdom_constructor("feOffset", allow_children=False) -fe_point_light = make_vdom_constructor("fePointLight", allow_children=False) -fe_specular_lighting = make_vdom_constructor("feSpecularLighting", allow_children=False) -fe_spot_light = make_vdom_constructor("feSpotLight", allow_children=False) -fe_tile = make_vdom_constructor("feTile", allow_children=False) -fe_turbulence = make_vdom_constructor("feTurbulence", allow_children=False) -filter = make_vdom_constructor("filter", allow_children=False) # noqa: A001 -foreign_object = make_vdom_constructor("foreignObject", allow_children=False) -g = make_vdom_constructor("g") -hatch = make_vdom_constructor("hatch", allow_children=False) -hatchpath = make_vdom_constructor("hatchpath", allow_children=False) -image = make_vdom_constructor("image", allow_children=False) -line = make_vdom_constructor("line", allow_children=False) -linear_gradient = make_vdom_constructor("linearGradient", allow_children=False) -marker = make_vdom_constructor("marker") -mask = make_vdom_constructor("mask") -metadata = make_vdom_constructor("metadata", allow_children=False) -mpath = make_vdom_constructor("mpath", allow_children=False) -path = make_vdom_constructor("path", allow_children=False) -pattern = make_vdom_constructor("pattern") -polygon = make_vdom_constructor("polygon", allow_children=False) -polyline = make_vdom_constructor("polyline", allow_children=False) -radial_gradient = make_vdom_constructor("radialGradient", allow_children=False) -rect = make_vdom_constructor("rect", allow_children=False) -script = make_vdom_constructor("script", allow_children=False) -set = make_vdom_constructor("set", allow_children=False) # noqa: A001 -stop = make_vdom_constructor("stop", allow_children=False) -style = make_vdom_constructor("style", allow_children=False) -svg = make_vdom_constructor("svg") -switch = make_vdom_constructor("switch") -symbol = make_vdom_constructor("symbol") -text = make_vdom_constructor("text", allow_children=False) -text_path = make_vdom_constructor("textPath", allow_children=False) -title = make_vdom_constructor("title", allow_children=False) -tspan = make_vdom_constructor("tspan", allow_children=False) -use = make_vdom_constructor("use", allow_children=False) -view = make_vdom_constructor("view", allow_children=False) diff --git a/src/py/reactpy/reactpy/testing/__init__.py b/src/py/reactpy/reactpy/testing/__init__.py deleted file mode 100644 index 9f61cec57..000000000 --- a/src/py/reactpy/reactpy/testing/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from reactpy.testing.backend import BackendFixture -from reactpy.testing.common import ( - HookCatcher, - StaticEventHandler, - clear_reactpy_web_modules_dir, - poll, -) -from reactpy.testing.display import DisplayFixture -from reactpy.testing.logs import ( - LogAssertionError, - assert_reactpy_did_log, - assert_reactpy_did_not_log, - capture_reactpy_logs, -) - -__all__ = [ - "assert_reactpy_did_not_log", - "assert_reactpy_did_log", - "capture_reactpy_logs", - "clear_reactpy_web_modules_dir", - "DisplayFixture", - "HookCatcher", - "LogAssertionError", - "poll", - "BackendFixture", - "StaticEventHandler", -] diff --git a/src/py/reactpy/reactpy/testing/backend.py b/src/py/reactpy/reactpy/testing/backend.py deleted file mode 100644 index 549e16056..000000000 --- a/src/py/reactpy/reactpy/testing/backend.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from contextlib import AsyncExitStack -from types import TracebackType -from typing import Any, Callable -from urllib.parse import urlencode, urlunparse - -from reactpy.backend import default as default_server -from reactpy.backend.types import BackendImplementation -from reactpy.backend.utils import find_available_port -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT -from reactpy.core.component import component -from reactpy.core.hooks import use_callback, use_effect, use_state -from reactpy.core.types import ComponentConstructor -from reactpy.testing.logs import ( - LogAssertionError, - capture_reactpy_logs, - list_logged_exceptions, -) -from reactpy.utils import Ref - - -class BackendFixture: - """A test fixture for running a server and imperatively displaying views - - This fixture is typically used alongside async web drivers like ``playwight``. - - Example: - .. code-block:: - - async with BackendFixture() as server: - server.mount(MyComponent) - """ - - _records: list[logging.LogRecord] - _server_future: asyncio.Task[Any] - _exit_stack = AsyncExitStack() - - def __init__( - self, - host: str = "127.0.0.1", - port: int | None = None, - app: Any | None = None, - implementation: BackendImplementation[Any] | None = None, - options: Any | None = None, - timeout: float | None = None, - ) -> None: - self.host = host - self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) - self.mount, self._root_component = _hotswap() - self.timeout = ( - REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout - ) - - if app is not None: - if implementation is None: - msg = "If an application instance its corresponding server implementation must be provided too." - raise ValueError(msg) - - self._app = app - self.implementation = implementation or default_server - self._options = options - - @property - def log_records(self) -> list[logging.LogRecord]: - """A list of captured log records""" - return self._records - - def url(self, path: str = "", query: Any | None = None) -> str: - """Return a URL string pointing to the host and point of the server - - Args: - path: the path to a resource on the server - query: a dictionary or list of query parameters - """ - return urlunparse( - [ - "http", - f"{self.host}:{self.port}", - path, - "", - urlencode(query or ()), - "", - ] - ) - - def list_logged_exceptions( - self, - pattern: str = "", - types: type[Any] | tuple[type[Any], ...] = Exception, - log_level: int = logging.ERROR, - del_log_records: bool = True, - ) -> list[BaseException]: - """Return a list of logged exception matching the given criteria - - Args: - log_level: The level of log to check - exclude_exc_types: Any exception types to ignore - del_log_records: Whether to delete the log records for yielded exceptions - """ - return list_logged_exceptions( - self.log_records, - pattern, - types, - log_level, - del_log_records, - ) - - async def __aenter__(self) -> BackendFixture: - self._exit_stack = AsyncExitStack() - self._records = self._exit_stack.enter_context(capture_reactpy_logs()) - - app = self._app or self.implementation.create_development_app() - self.implementation.configure(app, self._root_component, self._options) - - started = asyncio.Event() - server_future = asyncio.create_task( - self.implementation.serve_development_app( - app, self.host, self.port, started - ) - ) - - async def stop_server() -> None: - server_future.cancel() - try: - await asyncio.wait_for(server_future, timeout=self.timeout) - except asyncio.CancelledError: - pass - - self._exit_stack.push_async_callback(stop_server) - - try: - await asyncio.wait_for(started.wait(), timeout=self.timeout) - except Exception: # nocov - # see if we can await the future for a more helpful error - await asyncio.wait_for(server_future, timeout=self.timeout) - raise - - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - await self._exit_stack.aclose() - - self.mount(None) # reset the view - - logged_errors = self.list_logged_exceptions(del_log_records=False) - if logged_errors: # nocov - msg = "Unexpected logged exception" - raise LogAssertionError(msg) from logged_errors[0] - - -_MountFunc = Callable[["Callable[[], Any] | None"], None] - - -def _hotswap(update_on_change: bool = False) -> tuple[_MountFunc, ComponentConstructor]: - """Swap out components from a layout on the fly. - - Since you can't change the component functions used to create a layout - in an imperative manner, you can use ``hotswap`` to do this so - long as you set things up ahead of time. - - Parameters: - update_on_change: Whether or not all views of the layout should be updated on a swap. - - Example: - .. code-block:: python - - import reactpy - - show, root = reactpy.hotswap() - PerClientStateServer(root).run_in_thread("localhost", 8765) - - @reactpy.component - def DivOne(self): - return {"tagName": "div", "children": [1]} - - show(DivOne) - - # displaying the output now will show DivOne - - @reactpy.component - def DivTwo(self): - return {"tagName": "div", "children": [2]} - - show(DivTwo) - - # displaying the output now will show DivTwo - """ - constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: None) - - if update_on_change: - set_constructor_callbacks: set[Callable[[Callable[[], Any]], None]] = set() - - @component - def HotSwap() -> Any: - # new displays will adopt the latest constructor and arguments - constructor, _set_constructor = use_state(lambda: constructor_ref.current) - set_constructor = use_callback(lambda new: _set_constructor(lambda _: new)) - - def add_callback() -> Callable[[], None]: - set_constructor_callbacks.add(set_constructor) - return lambda: set_constructor_callbacks.remove(set_constructor) - - use_effect(add_callback) - - return constructor() - - def swap(constructor: Callable[[], Any] | None) -> None: - constructor = constructor_ref.current = constructor or (lambda: None) - - for set_constructor in set_constructor_callbacks: - set_constructor(constructor) - - else: - - @component - def HotSwap() -> Any: - return constructor_ref.current() - - def swap(constructor: Callable[[], Any] | None) -> None: - constructor_ref.current = constructor or (lambda: None) - - return swap, HotSwap diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py deleted file mode 100644 index 945c1c31d..000000000 --- a/src/py/reactpy/reactpy/testing/common.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -import asyncio -import inspect -import shutil -import time -from collections.abc import Awaitable -from functools import wraps -from typing import Any, Callable, Generic, TypeVar, cast -from uuid import uuid4 -from weakref import ref - -from typing_extensions import ParamSpec - -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR -from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.hooks import LifeCycleHook, current_hook - - -def clear_reactpy_web_modules_dir() -> None: - """Clear the directory where ReactPy stores registered web modules""" - for path in REACTPY_WEB_MODULES_DIR.current.iterdir(): - shutil.rmtree(path) if path.is_dir() else path.unlink() - - -_P = ParamSpec("_P") -_R = TypeVar("_R") -_RC = TypeVar("_RC", covariant=True) - - -_DEFAULT_POLL_DELAY = 0.1 - - -class poll(Generic[_R]): # noqa: N801 - """Wait until the result of an sync or async function meets some condition""" - - def __init__( - self, - function: Callable[_P, Awaitable[_R] | _R], - *args: _P.args, - **kwargs: _P.kwargs, - ) -> None: - coro: Callable[_P, Awaitable[_R]] - if not inspect.iscoroutinefunction(function): - - async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: - return cast(_R, function(*args, **kwargs)) - - else: - coro = cast(Callable[_P, Awaitable[_R]], function) - self._func = coro - self._args = args - self._kwargs = kwargs - - async def until( - self, - condition: Callable[[_R], bool], - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, - delay: float = _DEFAULT_POLL_DELAY, - description: str = "condition to be true", - ) -> None: - """Check that the coroutines result meets a condition within the timeout""" - started_at = time.time() - while True: - await asyncio.sleep(delay) - result = await self._func(*self._args, **self._kwargs) - if condition(result): - break - elif (time.time() - started_at) > timeout: # nocov - msg = f"Expected {description} after {timeout} seconds - last value was {result!r}" - raise TimeoutError(msg) - - async def until_is( - self, - right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, - delay: float = _DEFAULT_POLL_DELAY, - ) -> None: - """Wait until the result is identical to the given value""" - return await self.until( - lambda left: left is right, - timeout, - delay, - f"value to be identical to {right!r}", - ) - - async def until_equals( - self, - right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, - delay: float = _DEFAULT_POLL_DELAY, - ) -> None: - """Wait until the result is equal to the given value""" - return await self.until( - lambda left: left == right, - timeout, - delay, - f"value to equal {right!r}", - ) - - -class HookCatcher: - """Utility for capturing a LifeCycleHook from a component - - Example: - .. code-block:: - - hooks = HookCatcher(index_by_kwarg="thing") - - @reactpy.component - @hooks.capture - def MyComponent(thing): - ... - - ... # render the component - - # grab the last render of where MyComponent(thing='something') - hooks.index["something"] - # or grab the hook from the component's last render - hooks.latest - - After the first render of ``MyComponent`` the ``HookCatcher`` will have - captured the component's ``LifeCycleHook``. - """ - - latest: LifeCycleHook - - def __init__(self, index_by_kwarg: str | None = None): - self.index_by_kwarg = index_by_kwarg - self.index: dict[Any, LifeCycleHook] = {} - - def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: - """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" - - # The render function holds a reference to `self` and, via the `LifeCycleHook`, - # the component. Some tests check whether components are garbage collected, thus - # we must use a `ref` here to ensure these checks pass once the catcher itself - # has been collected. - self_ref = ref(self) - - @wraps(render_function) - def wrapper(*args: Any, **kwargs: Any) -> Any: - self = self_ref() - if self is None: - raise RuntimeError("Hook catcher has been garbage collected") - - hook = current_hook() - if self.index_by_kwarg is not None: - self.index[kwargs[self.index_by_kwarg]] = hook - self.latest = hook - return render_function(*args, **kwargs) - - return wrapper - - -class StaticEventHandler: - """Utility for capturing the target of one event handler - - Example: - .. code-block:: - - static_handler = StaticEventHandler() - - @reactpy.component - def MyComponent(): - state, set_state = reactpy.hooks.use_state(0) - handler = static_handler.use(lambda event: set_state(state + 1)) - return reactpy.html.button({"onClick": handler}, "Click me!") - - # gives the target ID for onClick where from the last render of MyComponent - static_handlers.target - - If you need to capture event handlers from different instances of a component - the you should create multiple ``StaticEventHandler`` instances. - - .. code-block:: - - static_handlers_by_key = { - "first": StaticEventHandler(), - "second": StaticEventHandler(), - } - - @reactpy.component - def Parent(): - return reactpy.html.div(Child(key="first"), Child(key="second")) - - @reactpy.component - def Child(key): - state, set_state = reactpy.hooks.use_state(0) - handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) - return reactpy.html.button({"onClick": handler}, "Click me!") - - # grab the individual targets for each instance above - first_target = static_handlers_by_key["first"].target - second_target = static_handlers_by_key["second"].target - """ - - def __init__(self) -> None: - self.target = uuid4().hex - - def use( - self, - function: Callable[..., Any], - stop_propagation: bool = False, - prevent_default: bool = False, - ) -> EventHandler: - return EventHandler( - to_event_handler_function(function), - stop_propagation, - prevent_default, - self.target, - ) diff --git a/src/py/reactpy/reactpy/testing/display.py b/src/py/reactpy/reactpy/testing/display.py deleted file mode 100644 index bb0d8351d..000000000 --- a/src/py/reactpy/reactpy/testing/display.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from contextlib import AsyncExitStack -from types import TracebackType -from typing import Any - -from playwright.async_api import ( - Browser, - BrowserContext, - ElementHandle, - Page, - async_playwright, -) - -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT -from reactpy.testing.backend import BackendFixture -from reactpy.types import RootComponentConstructor - - -class DisplayFixture: - """A fixture for running web-based tests using ``playwright``""" - - _exit_stack: AsyncExitStack - - def __init__( - self, - backend: BackendFixture | None = None, - driver: Browser | BrowserContext | Page | None = None, - url_prefix: str = "", - ) -> None: - if backend is not None: - self.backend = backend - if driver is not None: - if isinstance(driver, Page): - self.page = driver - else: - self._browser = driver - self.url_prefix = url_prefix - - async def show( - self, - component: RootComponentConstructor, - ) -> None: - self.backend.mount(component) - await self.goto("/") - await self.root_element() # check that root element is attached - - async def goto( - self, path: str, query: Any | None = None, add_url_prefix: bool = True - ) -> None: - await self.page.goto( - self.backend.url( - f"{self.url_prefix}{path}" if add_url_prefix else path, query - ) - ) - - async def root_element(self) -> ElementHandle: - element = await self.page.wait_for_selector("#app", state="attached") - if element is None: # nocov - msg = "Root element not attached" - raise RuntimeError(msg) - return element - - async def __aenter__(self) -> DisplayFixture: - es = self._exit_stack = AsyncExitStack() - - browser: Browser | BrowserContext - if not hasattr(self, "page"): - if not hasattr(self, "_browser"): - pw = await es.enter_async_context(async_playwright()) - browser = await pw.chromium.launch() - else: - browser = self._browser - self.page = await browser.new_page() - - self.page.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) - - if not hasattr(self, "backend"): - self.backend = BackendFixture() - await es.enter_async_context(self.backend) - - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - self.backend.mount(None) - await self._exit_stack.aclose() diff --git a/src/py/reactpy/reactpy/testing/logs.py b/src/py/reactpy/reactpy/testing/logs.py deleted file mode 100644 index e9337b19c..000000000 --- a/src/py/reactpy/reactpy/testing/logs.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -import logging -import re -from collections.abc import Iterator -from contextlib import contextmanager -from traceback import format_exception -from typing import Any, NoReturn - -from reactpy.logging import ROOT_LOGGER - - -class LogAssertionError(AssertionError): - """An assertion error raised in relation to log messages.""" - - -@contextmanager -def assert_reactpy_did_log( - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", -) -> Iterator[None]: - """Assert that ReactPy produced a log matching the described message or error. - - Args: - match_message: Must match a logged message. - error_type: Checks the type of logged exceptions. - match_error: Must match an error message. - """ - message_pattern = re.compile(match_message) - error_pattern = re.compile(match_error) - - with capture_reactpy_logs() as log_records: - try: - yield None - except Exception: - raise - else: - for record in list(log_records): - if ( - # record message matches - message_pattern.findall(record.getMessage()) - # error type matches - and ( - error_type is None - or ( - record.exc_info is not None - and record.exc_info[0] is not None - and issubclass(record.exc_info[0], error_type) - ) - ) - # error message pattern matches - and ( - not match_error - or ( - record.exc_info is not None - and error_pattern.findall( - "".join(format_exception(*record.exc_info)) - ) - ) - ) - ): - break - else: # nocov - _raise_log_message_error( - "Could not find a log record matching the given", - match_message, - error_type, - match_error, - ) - - -@contextmanager -def assert_reactpy_did_not_log( - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", -) -> Iterator[None]: - """Assert the inverse of :func:`assert_reactpy_logged`""" - try: - with assert_reactpy_did_log(match_message, error_type, match_error): - yield None - except LogAssertionError: - pass - else: - _raise_log_message_error( - "Did find a log record matching the given", - match_message, - error_type, - match_error, - ) - - -def list_logged_exceptions( - log_records: list[logging.LogRecord], - pattern: str = "", - types: type[Any] | tuple[type[Any], ...] = Exception, - log_level: int = logging.ERROR, - del_log_records: bool = True, -) -> list[BaseException]: - """Return a list of logged exception matching the given criteria - - Args: - log_level: The level of log to check - exclude_exc_types: Any exception types to ignore - del_log_records: Whether to delete the log records for yielded exceptions - """ - found: list[BaseException] = [] - compiled_pattern = re.compile(pattern) - for index, record in enumerate(log_records): - if record.levelno >= log_level and record.exc_info: - error = record.exc_info[1] - if ( - error is not None - and isinstance(error, types) - and compiled_pattern.search(str(error)) - ): - if del_log_records: - del log_records[index - len(found)] - found.append(error) - return found - - -@contextmanager -def capture_reactpy_logs() -> Iterator[list[logging.LogRecord]]: - """Capture logs from ReactPy - - Any logs produced in this context are cleared afterwards - """ - original_level = ROOT_LOGGER.level - ROOT_LOGGER.setLevel(logging.DEBUG) - try: - if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers: - start_index = len(_LOG_RECORD_CAPTOR.records) - try: - yield _LOG_RECORD_CAPTOR.records - finally: - end_index = len(_LOG_RECORD_CAPTOR.records) - _LOG_RECORD_CAPTOR.records[start_index:end_index] = [] - return None - - ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR) - try: - yield _LOG_RECORD_CAPTOR.records - finally: - ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR) - _LOG_RECORD_CAPTOR.records.clear() - finally: - ROOT_LOGGER.setLevel(original_level) - - -class _LogRecordCaptor(logging.NullHandler): - def __init__(self) -> None: - self.records: list[logging.LogRecord] = [] - super().__init__() - - def handle(self, record: logging.LogRecord) -> bool: - self.records.append(record) - return True - - -_LOG_RECORD_CAPTOR = _LogRecordCaptor() - - -def _raise_log_message_error( - prefix: str, - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", -) -> NoReturn: - conditions = [] - if match_message: - conditions.append(f"log message pattern {match_message!r}") - if error_type: - conditions.append(f"exception type {error_type}") - if match_error: - conditions.append(f"error message pattern {match_error!r}") - raise LogAssertionError(prefix + " " + " and ".join(conditions)) diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py deleted file mode 100644 index 715b66fff..000000000 --- a/src/py/reactpy/reactpy/types.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Exports common types from: - -- :mod:`reactpy.core.types` -- :mod:`reactpy.backend.types` -""" - -from reactpy.backend.types import BackendImplementation, Connection, Location -from reactpy.core.component import Component -from reactpy.core.hooks import Context -from reactpy.core.types import ( - ComponentConstructor, - ComponentType, - EventHandlerDict, - EventHandlerFunc, - EventHandlerMapping, - EventHandlerType, - ImportSourceDict, - Key, - LayoutType, - RootComponentConstructor, - State, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomJson, -) - -__all__ = [ - "BackendImplementation", - "Component", - "ComponentConstructor", - "ComponentType", - "Connection", - "Context", - "EventHandlerDict", - "EventHandlerFunc", - "EventHandlerMapping", - "EventHandlerType", - "ImportSourceDict", - "Key", - "LayoutType", - "Location", - "RootComponentConstructor", - "State", - "VdomAttributes", - "VdomChild", - "VdomChildren", - "VdomDict", - "VdomJson", -] diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py deleted file mode 100644 index e5e06d98d..000000000 --- a/src/py/reactpy/reactpy/utils.py +++ /dev/null @@ -1,307 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Iterable -from itertools import chain -from typing import Any, Callable, Generic, TypeVar, cast - -from lxml import etree -from lxml.html import fromstring, tostring - -from reactpy.core.types import VdomDict -from reactpy.core.vdom import vdom - -_RefValue = TypeVar("_RefValue") -_ModelTransform = Callable[[VdomDict], Any] -_UNDEFINED: Any = object() - - -class Ref(Generic[_RefValue]): - """Hold a reference to a value - - This is used in imperative code to mutate the state of this object in order to - incur side effects. Generally refs should be avoided if possible, but sometimes - they are required. - - Notes: - You can compare the contents for two ``Ref`` objects using the ``==`` operator. - """ - - __slots__ = "current" - - def __init__(self, initial_value: _RefValue = _UNDEFINED) -> None: - if initial_value is not _UNDEFINED: - self.current = initial_value - """The present value""" - - def set_current(self, new: _RefValue) -> _RefValue: - """Set the current value and return what is now the old value - - This is nice to use in ``lambda`` functions. - """ - old = self.current - self.current = new - return old - - def __eq__(self, other: Any) -> bool: - try: - return isinstance(other, Ref) and (other.current == self.current) - except AttributeError: - # attribute error occurs for uninitialized refs - return False - - def __repr__(self) -> str: - try: - current = repr(self.current) - except AttributeError: - # attribute error occurs for uninitialized refs - current = "<undefined>" - return f"{type(self).__name__}({current})" - - -def vdom_to_html(vdom: VdomDict) -> str: - """Convert a VDOM dictionary into an HTML string - - Only the following keys are translated to HTML: - - - ``tagName`` - - ``attributes`` - - ``children`` (must be strings or more VDOM dicts) - - Parameters: - vdom: The VdomDict element to convert to HTML - """ - temp_root = etree.Element("__temp__") - _add_vdom_to_etree(temp_root, vdom) - html = cast(bytes, tostring(temp_root)).decode() - # strip out temp root <__temp__> element - return html[10:-11] - - -def html_to_vdom( - html: str, *transforms: _ModelTransform, strict: bool = True -) -> VdomDict: - """Transform HTML into a DOM model. Unique keys can be provided to HTML elements - using a ``key=...`` attribute within your HTML tag. - - Parameters: - html: - The raw HTML as a string - transforms: - Functions of the form ``transform(old) -> new`` where ``old`` is a VDOM - dictionary which will be replaced by ``new``. For example, you could use a - transform function to add highlighting to a ``<code/>`` block. - strict: - If ``True``, raise an exception if the HTML does not perfectly follow HTML5 - syntax. - """ - if not isinstance(html, str): # nocov - msg = f"Expected html to be a string, not {type(html).__name__}" - raise TypeError(msg) - - # If the user provided a string, convert it to a list of lxml.etree nodes - try: - root_node: etree._Element = fromstring( - html.strip(), - parser=etree.HTMLParser( - remove_comments=True, - remove_pis=True, - remove_blank_text=True, - recover=not strict, - ), - ) - except etree.XMLSyntaxError as e: - if not strict: - raise e # nocov - msg = "An error has occurred while parsing the HTML.\n\nThis HTML may be malformatted, or may not perfectly adhere to HTML5.\nIf you believe the exception above was due to something intentional, you can disable the strict parameter on html_to_vdom().\nOtherwise, repair your broken HTML and try again." - raise HTMLParseError(msg) from e - - return _etree_to_vdom(root_node, transforms) - - -class HTMLParseError(etree.LxmlSyntaxError): # type: ignore[misc] - """Raised when an HTML document cannot be parsed using strict parsing.""" - - -def _etree_to_vdom( - node: etree._Element, transforms: Iterable[_ModelTransform] -) -> VdomDict: - """Transform an lxml etree node into a DOM model - - Parameters: - node: - The ``lxml.etree._Element`` node - transforms: - Functions of the form ``transform(old) -> new`` where ``old`` is a VDOM - dictionary which will be replaced by ``new``. For example, you could use a - transform function to add highlighting to a ``<code/>`` block. - """ - if not isinstance(node, etree._Element): # nocov - msg = f"Expected node to be a etree._Element, not {type(node).__name__}" - raise TypeError(msg) - - # Recursively call _etree_to_vdom() on all children - children = _generate_vdom_children(node, transforms) - - # Convert the lxml node to a VDOM dict - el = vdom(node.tag, dict(node.items()), *children) - - # Perform any necessary mutations on the VDOM attributes to meet VDOM spec - _mutate_vdom(el) - - # Apply any provided transforms. - for transform in transforms: - el = transform(el) - - return el - - -def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any]) -> None: - try: - tag = vdom["tagName"] - except KeyError as e: - msg = f"Expected a VDOM dict, not {vdom}" - raise TypeError(msg) from e - else: - vdom = cast(VdomDict, vdom) - - if tag: - element = etree.SubElement(parent, tag) - element.attrib.update( - _vdom_attr_to_html_str(k, v) for k, v in vdom.get("attributes", {}).items() - ) - else: - element = parent - - for c in vdom.get("children", []): - if isinstance(c, dict): - _add_vdom_to_etree(element, c) - else: - """ - LXML handles string children by storing them under `text` and `tail` - attributes of Element objects. The `text` attribute, if present, effectively - becomes that element's first child. Then the `tail` attribute, if present, - becomes a sibling that follows that element. For example, consider the - following HTML: - - <p><a>hello</a>world</p> - - In this code sample, "hello" is the `text` attribute of the `<a>` element - and "world" is the `tail` attribute of that same `<a>` element. It's for - this reason that, depending on whether the element being constructed has - non-string a child element, we need to assign a `text` vs `tail` attribute - to that element or the last non-string child respectively. - """ - if len(element): - last_child = element[-1] - last_child.tail = f"{last_child.tail or ''}{c}" - else: - element.text = f"{element.text or ''}{c}" - - -def _mutate_vdom(vdom: VdomDict) -> None: - """Performs any necessary mutations on the VDOM attributes to meet VDOM spec. - - Currently, this function only transforms the ``style`` attribute into a dictionary whose keys are - camelCase so as to be renderable by React. - - This function may be extended in the future. - """ - # Determine if the style attribute needs to be converted to a dict - if ( - "attributes" in vdom - and "style" in vdom["attributes"] - and isinstance(vdom["attributes"]["style"], str) - ): - # Convince type checker that it's safe to mutate attributes - assert isinstance(vdom["attributes"], dict) # noqa: S101 - - # Convert style attribute from str -> dict with camelCase keys - vdom["attributes"]["style"] = { - key.strip().replace("-", "_"): value.strip() - for key, value in ( - part.split(":", 1) - for part in vdom["attributes"]["style"].split(";") - if ":" in part - ) - } - - -def _generate_vdom_children( - node: etree._Element, transforms: Iterable[_ModelTransform] -) -> list[VdomDict | str]: - """Generates a list of VDOM children from an lxml node. - - Inserts inner text and/or tail text in between VDOM children, if necessary. - """ - return ( # Get the inner text of the current node - [node.text] if node.text else [] - ) + list( - chain( - *( - # Recursively convert each child node to VDOM - [_etree_to_vdom(child, transforms)] - # Insert the tail text between each child node - + ([child.tail] if child.tail else []) - for child in node.iterchildren(None) - ) - ) - ) - - -def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: - """Transform intended for use with `html_to_vdom`. - - Removes `<html>`, `<head>`, and `<body>` while preserving their children. - - Parameters: - vdom: - The VDOM dictionary to transform. - """ - if vdom["tagName"] in {"html", "body", "head"}: - return {"tagName": "", "children": vdom["children"]} - return vdom - - -def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: - if key == "style": - if isinstance(value, dict): - value = ";".join( - # We lower only to normalize - CSS is case-insensitive: - # https://www.w3.org/TR/css-fonts-3/#font-family-casing - f"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}" - for k, v in value.items() - ) - elif ( - # camel to data-* attributes - key.startswith("data_") - # camel to aria-* attributes - or key.startswith("aria_") - # handle special cases - or key in DASHED_HTML_ATTRS - ): - key = key.replace("_", "-") - elif ( - # camel to data-* attributes - key.startswith("data") - # camel to aria-* attributes - or key.startswith("aria") - # handle special cases - or key in DASHED_HTML_ATTRS - ): - key = _CAMEL_CASE_SUB_PATTERN.sub("-", key) - - if callable(value): # nocov - raise TypeError(f"Cannot convert callable attribute {key}={value} to HTML") - - # Again, we lower the attribute name only to normalize - HTML is case-insensitive: - # http://w3c.github.io/html-reference/documents.html#case-insensitivity - return key.lower(), str(value) - - -# see list of HTML attributes with dashes in them: -# https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list -DASHED_HTML_ATTRS = {"accept_charset", "acceptCharset", "http_equiv", "httpEquiv"} - -# Pattern for delimitting camelCase names (e.g. camelCase to camel-case) -_CAMEL_CASE_SUB_PATTERN = re.compile(r"(?<!^)(?=[A-Z])") diff --git a/src/py/reactpy/reactpy/web/__init__.py b/src/py/reactpy/reactpy/web/__init__.py deleted file mode 100644 index 308429dbb..000000000 --- a/src/py/reactpy/reactpy/web/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from reactpy.web.module import ( - export, - module_from_file, - module_from_string, - module_from_template, - module_from_url, -) - -__all__ = [ - "module_from_file", - "module_from_string", - "module_from_template", - "module_from_url", - "export", -] diff --git a/src/py/reactpy/reactpy/web/module.py b/src/py/reactpy/reactpy/web/module.py deleted file mode 100644 index 48322fe24..000000000 --- a/src/py/reactpy/reactpy/web/module.py +++ /dev/null @@ -1,390 +0,0 @@ -from __future__ import annotations - -import filecmp -import logging -import shutil -from dataclasses import dataclass -from pathlib import Path -from string import Template -from typing import Any, NewType, overload -from urllib.parse import urlparse - -from reactpy._warnings import warn -from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_WEB_MODULES_DIR -from reactpy.core.types import ImportSourceDict, VdomDictConstructor -from reactpy.core.vdom import make_vdom_constructor -from reactpy.web.utils import ( - module_name_suffix, - resolve_module_exports_from_file, - resolve_module_exports_from_url, -) - -logger = logging.getLogger(__name__) - -SourceType = NewType("SourceType", str) - -NAME_SOURCE = SourceType("NAME") -"""A named source - usually a Javascript package name""" - -URL_SOURCE = SourceType("URL") -"""A source loaded from a URL, usually a CDN""" - - -def module_from_url( - url: str, - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, -) -> WebModule: - """Load a :class:`WebModule` from a :data:`URL_SOURCE` - - Parameters: - url: - Where the javascript module will be loaded from which conforms to the - interface for :ref:`Custom Javascript Components` - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ - return WebModule( - source=url, - source_type=URL_SOURCE, - default_fallback=fallback, - file=None, - export_names=( - resolve_module_exports_from_url(url, resolve_exports_depth) - if ( - resolve_exports - if resolve_exports is not None - else REACTPY_DEBUG_MODE.current - ) - else None - ), - unmount_before_update=unmount_before_update, - ) - - -_FROM_TEMPLATE_DIR = "__from_template__" - - -def module_from_template( - template: str, - package: str, - cdn: str = "https://esm.sh", - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, -) -> WebModule: - """Create a :class:`WebModule` from a framework template - - This is useful for experimenting with component libraries that do not already - support ReactPy's :ref:`Custom Javascript Component` interface. - - .. warning:: - - This approach is not recommended for use in a production setting because the - framework templates may use unpinned dependencies that could change without - warning. It's best to author a module adhering to the - :ref:`Custom Javascript Component` interface instead. - - **Templates** - - - ``react``: for modules exporting React components - - Parameters: - template: - The name of the framework template to use with the given ``package``. - package: - The name of a package to load. May include a file extension (defaults to - ``.js`` if not given) - cdn: - Where the package should be loaded from. The CDN must distribute ESM modules - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ - warn( - "module_from_template() is deprecated due to instability - use the Javascript " - "Components API instead. This function will be removed in a future release.", - DeprecationWarning, - ) - template_name, _, template_version = template.partition("@") - template_version = "@" + template_version if template_version else "" - - # We do this since the package may be any valid URL path. Thus we may need to strip - # object parameters or query information so we save the resulting template under the - # correct file name. - package_name = urlparse(package).path - - # downstream code assumes no trailing slash - cdn = cdn.rstrip("/") - - template_file_name = template_name + module_name_suffix(package_name) - - template_file = Path(__file__).parent / "templates" / template_file_name - if not template_file.exists(): - msg = f"No template for {template_file_name!r} exists" - raise ValueError(msg) - - variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version} - content = Template(template_file.read_text()).substitute(variables) - - return module_from_string( - _FROM_TEMPLATE_DIR + "/" + package_name, - content, - fallback, - resolve_exports, - resolve_exports_depth, - unmount_before_update=unmount_before_update, - ) - - -def module_from_file( - name: str, - file: str | Path, - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, - symlink: bool = False, -) -> WebModule: - """Load a :class:`WebModule` from a given ``file`` - - Parameters: - name: - The name of the package - file: - The file from which the content of the web module will be created. - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - symlink: - Whether the web module should be saved as a symlink to the given ``file``. - """ - name += module_name_suffix(name) - - source_file = Path(file).resolve() - target_file = _web_module_path(name) - if not source_file.exists(): - msg = f"Source file does not exist: {source_file}" - raise FileNotFoundError(msg) - - if not target_file.exists(): - _copy_file(target_file, source_file, symlink) - elif not _equal_files(source_file, target_file): - logger.info( - f"Existing web module {name!r} will " - f"be replaced with {target_file.resolve()}" - ) - target_file.unlink() - _copy_file(target_file, source_file, symlink) - - return WebModule( - source=name, - source_type=NAME_SOURCE, - default_fallback=fallback, - file=target_file, - export_names=( - resolve_module_exports_from_file(source_file, resolve_exports_depth) - if ( - resolve_exports - if resolve_exports is not None - else REACTPY_DEBUG_MODE.current - ) - else None - ), - unmount_before_update=unmount_before_update, - ) - - -def _equal_files(f1: Path, f2: Path) -> bool: - f1 = f1.resolve() - f2 = f2.resolve() - return ( - (f1.is_symlink() or f2.is_symlink()) and (f1.resolve() == f2.resolve()) - ) or filecmp.cmp(str(f1), str(f2), shallow=False) - - -def _copy_file(target: Path, source: Path, symlink: bool) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - if symlink: - target.symlink_to(source) - else: - shutil.copy(source, target) - - -def module_from_string( - name: str, - content: str, - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, -) -> WebModule: - """Load a :class:`WebModule` whose ``content`` comes from a string. - - Parameters: - name: - The name of the package - content: - The contents of the web module - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ - name += module_name_suffix(name) - - target_file = _web_module_path(name) - - if target_file.exists() and target_file.read_text() != content: - logger.info( - f"Existing web module {name!r} will " - f"be replaced with {target_file.resolve()}" - ) - target_file.unlink() - - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_text(content) - - return WebModule( - source=name, - source_type=NAME_SOURCE, - default_fallback=fallback, - file=target_file, - export_names=( - resolve_module_exports_from_file(target_file, resolve_exports_depth) - if ( - resolve_exports - if resolve_exports is not None - else REACTPY_DEBUG_MODE.current - ) - else None - ), - unmount_before_update=unmount_before_update, - ) - - -@dataclass(frozen=True) -class WebModule: - source: str - source_type: SourceType - default_fallback: Any | None - export_names: set[str] | None - file: Path | None - unmount_before_update: bool - - -@overload -def export( - web_module: WebModule, - export_names: str, - fallback: Any | None = ..., - allow_children: bool = ..., -) -> VdomDictConstructor: - ... - - -@overload -def export( - web_module: WebModule, - export_names: list[str] | tuple[str, ...], - fallback: Any | None = ..., - allow_children: bool = ..., -) -> list[VdomDictConstructor]: - ... - - -def export( - web_module: WebModule, - export_names: str | list[str] | tuple[str, ...], - fallback: Any | None = None, - allow_children: bool = True, -) -> VdomDictConstructor | list[VdomDictConstructor]: - """Return one or more VDOM constructors from a :class:`WebModule` - - Parameters: - export_names: - One or more names to export. If given as a string, a single component - will be returned. If a list is given, then a list of components will be - returned. - fallback: - What to temporarily display while the module is being loaded. - allow_children: - Whether or not these components can have children. - """ - if isinstance(export_names, str): - if ( - web_module.export_names is not None - and export_names not in web_module.export_names - ): - msg = f"{web_module.source!r} does not export {export_names!r}" - raise ValueError(msg) - return _make_export(web_module, export_names, fallback, allow_children) - else: - if web_module.export_names is not None: - missing = sorted(set(export_names).difference(web_module.export_names)) - if missing: - msg = f"{web_module.source!r} does not export {missing!r}" - raise ValueError(msg) - return [ - _make_export(web_module, name, fallback, allow_children) - for name in export_names - ] - - -def _make_export( - web_module: WebModule, - name: str, - fallback: Any | None, - allow_children: bool, -) -> VdomDictConstructor: - return make_vdom_constructor( - name, - allow_children=allow_children, - import_source=ImportSourceDict( - source=web_module.source, - sourceType=web_module.source_type, - fallback=(fallback or web_module.default_fallback), - unmountBeforeUpdate=web_module.unmount_before_update, - ), - ) - - -def _web_module_path(name: str) -> Path: - directory = REACTPY_WEB_MODULES_DIR.current - path = directory.joinpath(*name.split("/")) - return path.with_suffix(path.suffix) diff --git a/src/py/reactpy/reactpy/web/templates/react.js b/src/py/reactpy/reactpy/web/templates/react.js deleted file mode 100644 index 5c6a45743..000000000 --- a/src/py/reactpy/reactpy/web/templates/react.js +++ /dev/null @@ -1,60 +0,0 @@ -export * from "$CDN/$PACKAGE"; - -import * as React from "$CDN/react$VERSION"; -import * as ReactDOM from "$CDN/react-dom$VERSION"; - -export default ({ children, ...props }) => { - const [{ component }, setComponent] = React.useState({}); - React.useEffect(() => { - import("$CDN/$PACKAGE").then((module) => { - // dynamically load the default export since we don't know if it's exported. - setComponent({ component: module.default }); - }); - }); - return component - ? React.createElement(component, props, ...(children || [])) - : null; -}; - -export function bind(node, config) { - return { - create: (component, props, children) => - React.createElement(component, wrapEventHandlers(props), ...children), - render: (element) => ReactDOM.render(element, node), - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; -} - -function wrapEventHandlers(props) { - const newProps = Object.assign({}, props); - for (const [key, value] of Object.entries(props)) { - if (typeof value === "function") { - newProps[key] = makeJsonSafeEventHandler(value); - } - } - return newProps; -} - -function makeJsonSafeEventHandler(oldHandler) { - // Since we can't really know what the event handlers get passed we have to check if - // they are JSON serializable or not. We can allow normal synthetic events to pass - // through since the original handler already knows how to serialize those for us. - return function safeEventHandler() { - oldHandler( - ...Array.from(arguments).filter((value) => { - if (typeof value === "object" && value.nativeEvent) { - // this is probably a standard React synthetic event - return true; - } else { - try { - JSON.stringify(value); - } catch (err) { - console.error("Failed to serialize some event data"); - return false; - } - return true; - } - }), - ); - }; -} diff --git a/src/py/reactpy/reactpy/web/utils.py b/src/py/reactpy/reactpy/web/utils.py deleted file mode 100644 index cf8b8638b..000000000 --- a/src/py/reactpy/reactpy/web/utils.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging -import re -from pathlib import Path, PurePosixPath -from urllib.parse import urlparse - -import requests - -logger = logging.getLogger(__name__) - - -def module_name_suffix(name: str) -> str: - if name.startswith("@"): - name = name[1:] - head, _, tail = name.partition("@") # handle version identifier - version, _, tail = tail.partition("/") # get section after version - return PurePosixPath(tail or head).suffix or ".js" - - -def resolve_module_exports_from_file( - file: Path, - max_depth: int, - is_re_export: bool = False, -) -> set[str]: - if max_depth == 0: - logger.warning(f"Did not resolve all exports for {file} - max depth reached") - return set() - elif not file.exists(): - logger.warning(f"Did not resolve exports for unknown file {file}") - return set() - - export_names, references = resolve_module_exports_from_source( - file.read_text(), exclude_default=is_re_export - ) - - for ref in references: - if urlparse(ref).scheme: # is an absolute URL - export_names.update( - resolve_module_exports_from_url(ref, max_depth - 1, is_re_export=True) - ) - else: - path = file.parent.joinpath(*ref.split("/")) - export_names.update( - resolve_module_exports_from_file(path, max_depth - 1, is_re_export=True) - ) - - return export_names - - -def resolve_module_exports_from_url( - url: str, - max_depth: int, - is_re_export: bool = False, -) -> set[str]: - if max_depth == 0: - logger.warning(f"Did not resolve all exports for {url} - max depth reached") - return set() - - try: - text = requests.get(url, timeout=5).text - except requests.exceptions.ConnectionError as error: - reason = "" if error is None else " - {error.errno}" - logger.warning("Did not resolve exports for url " + url + reason) - return set() - - export_names, references = resolve_module_exports_from_source( - text, exclude_default=is_re_export - ) - - for ref in references: - url = _resolve_relative_url(url, ref) - export_names.update( - resolve_module_exports_from_url(url, max_depth - 1, is_re_export=True) - ) - - return export_names - - -def resolve_module_exports_from_source( - content: str, exclude_default: bool -) -> tuple[set[str], set[str]]: - names: set[str] = set() - references: set[str] = set() - - if _JS_DEFAULT_EXPORT_PATTERN.search(content): - names.add("default") - - # Exporting functions and classes - names.update(_JS_FUNC_OR_CLS_EXPORT_PATTERN.findall(content)) - - for export in _JS_GENERAL_EXPORT_PATTERN.findall(content): - export = export.rstrip(";").strip() - # Exporting individual features - if export.startswith("let "): - names.update(let.split("=", 1)[0] for let in export[4:].split(",")) - # Renaming exports and export list - elif export.startswith("{") and export.endswith("}"): - names.update( - item.split(" as ", 1)[-1] for item in export.strip("{}").split(",") - ) - # Exporting destructured assignments with renaming - elif export.startswith("const "): - names.update( - item.split(":", 1)[0] - for item in export[6:].split("=", 1)[0].strip("{}").split(",") - ) - # Default exports - elif export.startswith("default "): - names.add("default") - # Aggregating modules - elif export.startswith("* as "): - names.add(export[5:].split(" from ", 1)[0]) - elif export.startswith("* "): - references.add(export[2:].split("from ", 1)[-1].strip("'\"")) - elif export.startswith("{") and " from " in export: - names.update( - item.split(" as ", 1)[-1] - for item in export.split(" from ")[0].strip("{}").split(",") - ) - elif not (export.startswith("function ") or export.startswith("class ")): - logger.warning(f"Unknown export type {export!r}") - - names = {n.strip() for n in names} - references = {r.strip() for r in references} - - if exclude_default and "default" in names: - names.remove("default") - - return names, references - - -def _resolve_relative_url(base_url: str, rel_url: str) -> str: - if not rel_url.startswith("."): - return rel_url - - base_url = base_url.rsplit("/", 1)[0] - - if rel_url.startswith("./"): - return base_url + rel_url[1:] - - while rel_url.startswith("../"): - base_url = base_url.rsplit("/", 1)[0] - rel_url = rel_url[3:] - - return f"{base_url}/{rel_url}" - - -_JS_DEFAULT_EXPORT_PATTERN = re.compile( - r";?\s*export\s+default\s", -) -_JS_FUNC_OR_CLS_EXPORT_PATTERN = re.compile( - r";?\s*export\s+(?:function|class)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)" -) -_JS_GENERAL_EXPORT_PATTERN = re.compile( - r"(?:^|;|})\s*export(?=\s+|{)(.*?)(?=;|$)", re.MULTILINE -) diff --git a/src/py/reactpy/reactpy/widgets.py b/src/py/reactpy/reactpy/widgets.py deleted file mode 100644 index cc19be04d..000000000 --- a/src/py/reactpy/reactpy/widgets.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from base64 import b64encode -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar - -import reactpy -from reactpy import html -from reactpy._warnings import warn -from reactpy.core.types import ComponentConstructor, VdomDict - - -def image( - format: str, - value: str | bytes = "", - attributes: dict[str, Any] | None = None, -) -> VdomDict: - """Utility for constructing an image from a string or bytes - - The source value will automatically be encoded to base64 - """ - if format == "svg": - format = "svg+xml" # noqa: A001 - - if isinstance(value, str): - bytes_value = value.encode() - else: - bytes_value = value - - base64_value = b64encode(bytes_value).decode() - src = f"data:image/{format};base64,{base64_value}" - - return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} - - -_Value = TypeVar("_Value") - - -def use_linked_inputs( - attributes: Sequence[dict[str, Any]], - on_change: Callable[[_Value], None] = lambda value: None, - cast: _CastFunc[_Value] = lambda value: value, - initial_value: str = "", - ignore_empty: bool = True, -) -> list[VdomDict]: - """Return a list of linked inputs equal to the number of given attributes. - - Parameters: - attributes: - That attributes of each returned input element. If the number of generated - inputs is variable, you may need to assign each one a - :ref:`key <Organizing Items With Keys>` by including a ``"key"`` in each - attribute dictionary. - on_change: - A callback which is triggered when any input is changed. This callback need - not update the 'value' field in the attributes of the inputs since that is - handled automatically. - cast: - Cast the 'value' of changed inputs that is passed to ``on_change``. - initial_value: - Initialize the 'value' field of the inputs. - ignore_empty: - Do not trigger ``on_change`` if the 'value' is an empty string. - """ - value, set_value = reactpy.hooks.use_state(initial_value) - - def sync_inputs(event: dict[str, Any]) -> None: - new_value = event["target"]["value"] - set_value(new_value) - if not new_value and ignore_empty: - return None - on_change(cast(new_value)) - - inputs: list[VdomDict] = [] - for attrs in attributes: - inputs.append(html.input({**attrs, "on_change": sync_inputs, "value": value})) - - return inputs - - -_CastTo = TypeVar("_CastTo", covariant=True) - - -class _CastFunc(Protocol[_CastTo]): - def __call__(self, value: str) -> _CastTo: - ... - - -if TYPE_CHECKING: - from reactpy.testing.backend import _MountFunc - - -def hotswap( - update_on_change: bool = False, -) -> tuple[_MountFunc, ComponentConstructor]: # nocov - warn( - "The 'hotswap' function is deprecated and will be removed in a future release", - DeprecationWarning, - stacklevel=2, - ) - from reactpy.testing.backend import _hotswap - - return _hotswap(update_on_change) diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py deleted file mode 100644 index 21b23c12e..000000000 --- a/src/py/reactpy/tests/conftest.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import asyncio -import os - -import pytest -from _pytest.config import Config -from _pytest.config.argparsing import Parser -from playwright.async_api import async_playwright - -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT -from reactpy.testing import ( - BackendFixture, - DisplayFixture, - capture_reactpy_logs, - clear_reactpy_web_modules_dir, -) -from tests.tooling.loop import open_event_loop - - -def pytest_addoption(parser: Parser) -> None: - parser.addoption( - "--headed", - dest="headed", - action="store_true", - help="Open a browser window when running web-based tests", - ) - - -@pytest.fixture -async def display(server, page): - async with DisplayFixture(server, page) as display: - yield display - - -@pytest.fixture(scope="session") -async def server(): - async with BackendFixture() as server: - yield server - - -@pytest.fixture(scope="session") -async def page(browser): - pg = await browser.new_page() - pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) - try: - yield pg - finally: - await pg.close() - - -@pytest.fixture(scope="session") -async def browser(pytestconfig: Config): - async with async_playwright() as pw: - yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) - - -@pytest.fixture(scope="session") -def event_loop(): - if os.name == "nt": # nocov - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - with open_event_loop() as loop: - yield loop - - -@pytest.fixture(autouse=True) -def clear_web_modules_dir_after_test(): - clear_reactpy_web_modules_dir() - - -@pytest.fixture(autouse=True) -def assert_no_logged_exceptions(): - with capture_reactpy_logs() as records: - yield - try: - for r in records: - if r.exc_info is not None: - raise r.exc_info[1] - finally: - records.clear() diff --git a/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py b/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py deleted file mode 100644 index 47b8baabc..000000000 --- a/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py +++ /dev/null @@ -1,118 +0,0 @@ -import sys -from pathlib import Path -from textwrap import dedent - -import pytest -from click.testing import CliRunner - -from reactpy._console.rewrite_camel_case_props import ( - generate_rewrite, - rewrite_camel_case_props, -) - -if sys.version_info < (3, 9): - pytestmark = pytest.mark.skip(reason="ast.unparse is Python>=3.9") - - -def test_rewrite_camel_case_props_declarations(tmp_path): - runner = CliRunner() - - tempfile: Path = tmp_path / "temp.py" - tempfile.write_text("html.div(dict(camelCase='test'))") - result = runner.invoke( - rewrite_camel_case_props, - args=[str(tmp_path)], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert tempfile.read_text() == "html.div(dict(camel_case='test'))" - - -def test_rewrite_camel_case_props_declarations_no_files(): - runner = CliRunner() - - result = runner.invoke( - rewrite_camel_case_props, - args=["directory-does-no-exist"], - catch_exceptions=False, - ) - - assert result.exit_code != 0 - - -@pytest.mark.parametrize( - "source, expected", - [ - ( - "html.div(dict(camelCase='test'))", - "html.div(dict(camel_case='test'))", - ), - ( - "reactpy.html.button({'onClick': block_forever})", - "reactpy.html.button({'on_click': block_forever})", - ), - ( - "html.div(dict(style={'testThing': test}))", - "html.div(dict(style={'test_thing': test}))", - ), - ( - "html.div(dict(style=dict(testThing=test)))", - "html.div(dict(style=dict(test_thing=test)))", - ), - ( - "vdom('tag', dict(camelCase='test'))", - "vdom('tag', dict(camel_case='test'))", - ), - ( - "vdom('tag', dict(camelCase='test', **props))", - "vdom('tag', dict(camel_case='test', **props))", - ), - ( - "html.div({'camelCase': test, 'data-thing': test})", - "html.div({'camel_case': test, 'data-thing': test})", - ), - ( - "html.div({'camelCase': test, ignore: this})", - "html.div({'camel_case': test, ignore: this})", - ), - # no rewrite - ( - "html.div({'snake_case': test})", - None, - ), - ( - "html.div({'data-case': test})", - None, - ), - ( - "html.div(dict(snake_case='test'))", - None, - ), - ( - "html.div()", - None, - ), - ( - "vdom('tag')", - None, - ), - ( - "html.div('child')", - None, - ), - ( - "vdom('tag', 'child')", - None, - ), - ], - ids=lambda item: " ".join(map(str.strip, item.split())) - if isinstance(item, str) - else item, -) -def test_generate_rewrite(source, expected): - actual = generate_rewrite(Path("test.py"), dedent(source).strip()) - if isinstance(expected, str): - expected = dedent(expected).strip() - - assert actual == expected diff --git a/src/py/reactpy/tests/test__console/test_rewrite_keys.py b/src/py/reactpy/tests/test__console/test_rewrite_keys.py deleted file mode 100644 index da0b26c4f..000000000 --- a/src/py/reactpy/tests/test__console/test_rewrite_keys.py +++ /dev/null @@ -1,237 +0,0 @@ -import sys -from pathlib import Path -from textwrap import dedent - -import pytest -from click.testing import CliRunner - -from reactpy._console.rewrite_keys import generate_rewrite, rewrite_keys - -if sys.version_info < (3, 9): - pytestmark = pytest.mark.skip(reason="ast.unparse is Python>=3.9") - - -def test_rewrite_key_declarations(tmp_path): - runner = CliRunner() - - tempfile: Path = tmp_path / "temp.py" - tempfile.write_text("html.div(key='test')") - result = runner.invoke( - rewrite_keys, - args=[str(tmp_path)], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert tempfile.read_text() == "html.div({'key': 'test'})" - - -def test_rewrite_key_declarations_no_files(): - runner = CliRunner() - - result = runner.invoke( - rewrite_keys, - args=["directory-does-no-exist"], - catch_exceptions=False, - ) - - assert result.exit_code != 0 - - -@pytest.mark.parametrize( - "source, expected", - [ - ( - "html.div(key='test')", - "html.div({'key': 'test'})", - ), - ( - "html.div('something', key='test')", - "html.div({'key': 'test'}, 'something')", - ), - ( - "html.div({'some_attr': 1}, child_1, child_2, key='test')", - "html.div({'some_attr': 1, 'key': 'test'}, child_1, child_2)", - ), - ( - "vdom('div', key='test')", - "vdom('div', {'key': 'test'})", - ), - ( - "vdom('div', 'something', key='test')", - "vdom('div', {'key': 'test'}, 'something')", - ), - ( - "vdom('div', {'some_attr': 1}, child_1, child_2, key='test')", - "vdom('div', {'some_attr': 1, 'key': 'test'}, child_1, child_2)", - ), - ( - "html.div(dict(some_attr=1), child_1, child_2, key='test')", - "html.div(dict(some_attr=1, key='test'), child_1, child_2)", - ), - ( - "vdom('div', dict(some_attr=1), child_1, child_2, key='test')", - "vdom('div', dict(some_attr=1, key='test'), child_1, child_2)", - ), - # avoid unnecessary changes - ( - """ - def my_function(): - x = 1 # some comment - return html.div(key='test') - """, - """ - def my_function(): - x = 1 # some comment - return html.div({'key': 'test'}) - """, - ), - ( - """ - if condition: - # some comment - dom = html.div(key='test') - """, - """ - if condition: - # some comment - dom = html.div({'key': 'test'}) - """, - ), - ( - """ - [ - html.div(key='test'), - html.div(key='test'), - ] - """, - """ - [ - html.div({'key': 'test'}), - html.div({'key': 'test'}), - ] - """, - ), - ( - """ - @deco( - html.div(key='test'), - html.div(key='test'), - ) - def func(): - # comment - x = [ - 1 - ] - """, - """ - @deco( - html.div({'key': 'test'}), - html.div({'key': 'test'}), - ) - def func(): - # comment - x = [ - 1 - ] - """, - ), - ( - """ - @deco(html.div(key='test'), html.div(key='test')) - def func(): - # comment - x = [ - 1 - ] - """, - """ - @deco(html.div({'key': 'test'}), html.div({'key': 'test'})) - def func(): - # comment - x = [ - 1 - ] - """, - ), - ( - """ - ( - result - if condition - else html.div(key='test') - ) - """, - """ - ( - result - if condition - else html.div({'key': 'test'}) - ) - """, - ), - # best effort to preserve comments - ( - """ - x = 1 - html.div( - "hello", - # comment 1 - html.div(key='test'), - # comment 2 - key='test', - ) - """, - """ - x = 1 - # comment 1 - # comment 2 - html.div({'key': 'test'}, 'hello', html.div({'key': 'test'})) - """, - ), - # no rewrites - ( - "html.no_an_element(key='test')", - None, - ), - ( - "not_html.div(key='test')", - None, - ), - ( - "html.div()", - None, - ), - ( - "html.div(not_key='something')", - None, - ), - ( - "vdom()", - None, - ), - ( - "(some + expr)(key='test')", - None, - ), - ("html.div()", None), - # too ambiguous to rewrite - ( - "html.div(child_1, child_2, key='test')", # unclear if child_1 is attr dict - None, - ), - ( - "vdom('div', child_1, child_2, key='test')", # unclear if child_1 is attr dict - None, - ), - ], - ids=lambda item: " ".join(map(str.strip, item.split())) - if isinstance(item, str) - else item, -) -def test_generate_rewrite(source, expected): - actual = generate_rewrite(Path("test.py"), dedent(source).strip()) - if isinstance(expected, str): - expected = dedent(expected).strip() - - assert actual == expected diff --git a/src/py/reactpy/tests/test__option.py b/src/py/reactpy/tests/test__option.py deleted file mode 100644 index 63f2fada8..000000000 --- a/src/py/reactpy/tests/test__option.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -from unittest import mock - -import pytest - -from reactpy._option import DeprecatedOption, Option - - -def test_option_repr(): - opt = Option("A_FAKE_OPTION", "some-value") - assert opt.name == "A_FAKE_OPTION" - assert repr(opt) == "Option(A_FAKE_OPTION='some-value')" - - -@mock.patch.dict(os.environ, {"A_FAKE_OPTION": "value-from-environ"}) -def test_option_from_os_environ(): - opt = Option("A_FAKE_OPTION", "default-value") - assert opt.current == "value-from-environ" - - -def test_option_from_default(): - opt = Option("A_FAKE_OPTION", "default-value") - assert opt.current == "default-value" - assert opt.current is opt.default - - -@mock.patch.dict(os.environ, {"A_FAKE_OPTION": "1"}) -def test_option_validator(): - opt = Option("A_FAKE_OPTION", False, validator=lambda x: bool(int(x))) - - assert opt.current is True - - opt.current = "0" - assert opt.current is False - - with pytest.raises(ValueError, match="invalid literal for int"): - opt.current = "not-an-int" - - -def test_immutable_option(): - opt = Option("A_FAKE_OPTION", "default-value", mutable=False) - assert not opt.mutable - with pytest.raises(TypeError, match="cannot be modified after initial load"): - opt.current = "a-new-value" - with pytest.raises(TypeError, match="cannot be modified after initial load"): - opt.unset() - - -def test_option_reset(): - opt = Option("A_FAKE_OPTION", "default-value") - opt.current = "a-new-value" - opt.unset() - assert opt.current is opt.default - assert not opt.is_set() - - -@mock.patch.dict(os.environ, {"A_FAKE_OPTION": "value-from-environ"}) -def test_option_reload(): - opt = Option("A_FAKE_OPTION", "default-value") - opt.current = "some-other-value" - opt.reload() - assert opt.current == "value-from-environ" - - -def test_option_set(): - opt = Option("A_FAKE_OPTION", "default-value") - assert not opt.is_set() - opt.current = "a-new-value" - assert opt.is_set() - - -def test_option_set_default(): - opt = Option("A_FAKE_OPTION", "default-value") - assert not opt.is_set() - assert opt.set_default("new-value") == "new-value" - assert opt.is_set() - - -def test_cannot_subscribe_immutable_option(): - opt = Option("A_FAKE_OPTION", "default", mutable=False) - with pytest.raises(TypeError, match="Immutable options cannot be subscribed to"): - opt.subscribe(lambda value: None) - - -def test_option_subscribe(): - opt = Option("A_FAKE_OPTION", "default") - - calls = [] - opt.subscribe(calls.append) - assert calls == ["default"] - - opt.current = "default" - # value did not change, so no trigger - assert calls == ["default"] - - opt.current = "new-1" - opt.current = "new-2" - assert calls == ["default", "new-1", "new-2"] - - opt.unset() - assert calls == ["default", "new-1", "new-2", "default"] - - -def test_deprecated_option(): - opt = DeprecatedOption("is deprecated!", "A_FAKE_OPTION", None) - - with pytest.warns(DeprecationWarning, match="is deprecated!"): - assert opt.current is None - - with pytest.warns(DeprecationWarning, match="is deprecated!"): - opt.current = "something" diff --git a/src/py/reactpy/tests/test_backend/test__common.py b/src/py/reactpy/tests/test_backend/test__common.py deleted file mode 100644 index 248bf9451..000000000 --- a/src/py/reactpy/tests/test_backend/test__common.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest - -from reactpy import html -from reactpy.backend._common import ( - CommonOptions, - traversal_safe_path, - vdom_head_elements_to_html, -) - - -def test_common_options_url_prefix_starts_with_slash(): - # no prefix specified - CommonOptions(url_prefix="") - - with pytest.raises(ValueError, match="start with '/'"): - CommonOptions(url_prefix="not-start-withslash") - - -@pytest.mark.parametrize( - "bad_path", - [ - "../escaped", - "ok/../../escaped", - "ok/ok-again/../../ok-yet-again/../../../escaped", - ], -) -def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError, match="Unsafe path"): - traversal_safe_path(tmp_path, *bad_path.split("/")) - - -@pytest.mark.parametrize( - "vdom_in, html_out", - [ - ( - "<title>example</title>", - "<title>example</title>", - ), - ( - # We do not modify strings given by user. If given as VDOM we would have - # striped this head element, but since provided as string, we leav as-is. - "<head></head>", - "<head></head>", - ), - ( - html.head( - html.meta({"charset": "utf-8"}), - html.title("example"), - ), - # we strip the head element - '<meta charset="utf-8"><title>example</title>', - ), - ( - html._( - html.meta({"charset": "utf-8"}), - html.title("example"), - ), - '<meta charset="utf-8"><title>example</title>', - ), - ( - [ - html.meta({"charset": "utf-8"}), - html.title("example"), - ], - '<meta charset="utf-8"><title>example</title>', - ), - ], -) -def test_vdom_head_elements_to_html(vdom_in, html_out): - assert vdom_head_elements_to_html(vdom_in) == html_out diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py deleted file mode 100644 index 11b9693a2..000000000 --- a/src/py/reactpy/tests/test_backend/test_all.py +++ /dev/null @@ -1,174 +0,0 @@ -from collections.abc import MutableMapping - -import pytest - -import reactpy -from reactpy import html -from reactpy.backend import default as default_implementation -from reactpy.backend._common import PATH_PREFIX -from reactpy.backend.types import BackendImplementation, Connection, Location -from reactpy.backend.utils import all_implementations -from reactpy.testing import BackendFixture, DisplayFixture, poll - - -@pytest.fixture( - params=[*list(all_implementations()), default_implementation], - ids=lambda imp: imp.__name__, - scope="module", -) -async def display(page, request): - imp: BackendImplementation = request.param - - # we do this to check that route priorities for each backend are correct - if imp is default_implementation: - url_prefix = "" - opts = None - else: - url_prefix = str(PATH_PREFIX) - opts = imp.Options(url_prefix=url_prefix) - - async with BackendFixture(implementation=imp, options=opts) as server: - async with DisplayFixture( - backend=server, - driver=page, - url_prefix=url_prefix, - ) as display: - yield display - - -async def test_display_simple_hello_world(display: DisplayFixture): - @reactpy.component - def Hello(): - return reactpy.html.p({"id": "hello"}, ["Hello World"]) - - await display.show(Hello) - - await display.page.wait_for_selector("#hello") - - # test that we can reconnect successfully - await display.page.reload() - - await display.page.wait_for_selector("#hello") - - -async def test_display_simple_click_counter(display: DisplayFixture): - @reactpy.component - def Counter(): - count, set_count = reactpy.hooks.use_state(0) - return reactpy.html.button( - { - "id": "counter", - "on_click": lambda event: set_count(lambda old_count: old_count + 1), - }, - f"Count: {count}", - ) - - await display.show(Counter) - - counter = await display.page.wait_for_selector("#counter") - - for i in range(5): - await poll(counter.text_content).until_equals(f"Count: {i}") - await counter.click() - - -async def test_module_from_template(display: DisplayFixture): - victory = reactpy.web.module_from_template("react", "victory-bar@35.4.0") - VictoryBar = reactpy.web.export(victory, "VictoryBar") - await display.show(VictoryBar) - await display.page.wait_for_selector(".VictoryContainer") - - -async def test_use_connection(display: DisplayFixture): - conn = reactpy.Ref() - - @reactpy.component - def ShowScope(): - conn.current = reactpy.use_connection() - return html.pre({"id": "scope"}, str(conn.current)) - - await display.show(ShowScope) - - await display.page.wait_for_selector("#scope") - assert isinstance(conn.current, Connection) - - -async def test_use_scope(display: DisplayFixture): - scope = reactpy.Ref() - - @reactpy.component - def ShowScope(): - scope.current = reactpy.use_scope() - return html.pre({"id": "scope"}, str(scope.current)) - - await display.show(ShowScope) - - await display.page.wait_for_selector("#scope") - assert isinstance(scope.current, MutableMapping) - - -async def test_use_location(display: DisplayFixture): - location = reactpy.Ref() - - @poll - async def poll_location(): - """This needs to be async to allow the server to respond""" - return location.current - - @reactpy.component - def ShowRoute(): - location.current = reactpy.use_location() - return html.pre(str(location.current)) - - await display.show(ShowRoute) - - await poll_location.until_equals(Location("/", "")) - - for loc in [ - Location("/something", ""), - Location("/something/file.txt", ""), - Location("/another/something", ""), - Location("/another/something/file.txt", ""), - Location("/another/something/file.txt", "?key=value"), - Location("/another/something/file.txt", "?key1=value1&key2=value2"), - ]: - await display.goto(loc.pathname + loc.search) - await poll_location.until_equals(loc) - - -@pytest.mark.parametrize("hook_name", ["use_request", "use_websocket"]) -async def test_use_request(display: DisplayFixture, hook_name): - hook = getattr(display.backend.implementation, hook_name, None) - if hook is None: - pytest.skip(f"{display.backend.implementation} has no '{hook_name}' hook") - - hook_val = reactpy.Ref() - - @reactpy.component - def ShowRoute(): - hook_val.current = hook() - return html.pre({"id": "hook"}, str(hook_val.current)) - - await display.show(ShowRoute) - - await display.page.wait_for_selector("#hook") - - # we can't easily narrow this check - assert hook_val.current is not None - - -@pytest.mark.parametrize("imp", all_implementations()) -async def test_customized_head(imp: BackendImplementation, page): - custom_title = f"Custom Title for {imp.__name__}" - - @reactpy.component - def sample(): - return html.h1(f"^ Page title is customized to: '{custom_title}'") - - async with BackendFixture( - implementation=imp, - options=imp.Options(head=html.title(custom_title)), - ) as server: - async with DisplayFixture(backend=server, driver=page) as display: - await display.show(sample) - assert (await display.page.title()) == custom_title diff --git a/src/py/reactpy/tests/test_backend/test_utils.py b/src/py/reactpy/tests/test_backend/test_utils.py deleted file mode 100644 index 2a58dc62a..000000000 --- a/src/py/reactpy/tests/test_backend/test_utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import threading -import time -from contextlib import ExitStack - -import pytest -from playwright.async_api import Page - -from reactpy.backend import flask as flask_implementation -from reactpy.backend.utils import find_available_port -from reactpy.backend.utils import run as sync_run -from reactpy.sample import SampleApp - - -@pytest.fixture -def exit_stack(): - with ExitStack() as es: - yield es - - -def test_find_available_port(): - assert find_available_port("localhost", port_min=5000, port_max=6000) - with pytest.raises(RuntimeError, match="no available port"): - # check that if port range is exhausted we raise - find_available_port("localhost", port_min=0, port_max=0) - - -async def test_run(page: Page): - host = "127.0.0.1" - port = find_available_port(host) - url = f"http://{host}:{port}" - - threading.Thread( - target=lambda: sync_run( - SampleApp, - host, - port, - implementation=flask_implementation, - ), - daemon=True, - ).start() - - # give the server a moment to start - time.sleep(0.5) - - await page.goto(url) - await page.wait_for_selector("#sample") diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py deleted file mode 100644 index 3c7250e48..000000000 --- a/src/py/reactpy/tests/test_client.py +++ /dev/null @@ -1,161 +0,0 @@ -import asyncio -from contextlib import AsyncExitStack -from pathlib import Path - -from playwright.async_api import Browser - -import reactpy -from reactpy.backend.utils import find_available_port -from reactpy.testing import BackendFixture, DisplayFixture, poll -from tests.tooling.common import DEFAULT_TYPE_DELAY -from tests.tooling.hooks import use_counter - -JS_DIR = Path(__file__).parent / "js" - - -async def test_automatic_reconnect(browser: Browser): - port = find_available_port("localhost") - page = await browser.new_page() - - # we need to wait longer here because the automatic reconnect is not instant - page.set_default_timeout(10000) - - @reactpy.component - def SomeComponent(): - count, incr_count = use_counter(0) - return reactpy.html._( - reactpy.html.p({"data_count": count, "id": "count"}, "count", count), - reactpy.html.button( - {"on_click": lambda e: incr_count(), "id": "incr"}, "incr" - ), - ) - - async with AsyncExitStack() as exit_stack: - server = await exit_stack.enter_async_context(BackendFixture(port=port)) - display = await exit_stack.enter_async_context( - DisplayFixture(server, driver=page) - ) - - await display.show(SomeComponent) - - count = await page.wait_for_selector("#count") - incr = await page.wait_for_selector("#incr") - - for i in range(3): - assert (await count.get_attribute("data-count")) == str(i) - await incr.click() - - # the server is disconnected but the last view state is still shown - await page.wait_for_selector("#count") - - async with AsyncExitStack() as exit_stack: - server = await exit_stack.enter_async_context(BackendFixture(port=port)) - display = await exit_stack.enter_async_context( - DisplayFixture(server, driver=page) - ) - - # use mount instead of show to avoid a page refresh - display.backend.mount(SomeComponent) - - async def get_count(): - # need to refetch element because may unmount on reconnect - count = await page.wait_for_selector("#count") - return await count.get_attribute("data-count") - - for i in range(3): - # it may take a moment for the websocket to reconnect so need to poll - await poll(get_count).until_equals(str(i)) - - # need to refetch element because may unmount on reconnect - incr = await page.wait_for_selector("#incr") - - await incr.click() - - -async def test_style_can_be_changed(display: DisplayFixture): - """This test was introduced to verify the client does not mutate the model - - A bug was introduced where the client-side model was mutated and React was relying - on the model to have been copied in order to determine if something had changed. - - See for more info: https://github.com/reactive-python/reactpy/issues/480 - """ - - @reactpy.component - def ButtonWithChangingColor(): - color_toggle, set_color_toggle = reactpy.hooks.use_state(True) - color = "red" if color_toggle else "blue" - return reactpy.html.button( - { - "id": "my-button", - "on_click": lambda event: set_color_toggle(not color_toggle), - "style": {"background_color": color, "color": "white"}, - }, - f"color: {color}", - ) - - await display.show(ButtonWithChangingColor) - - button = await display.page.wait_for_selector("#my-button") - - assert (await _get_style(button))["background-color"] == "red" - - for color in ["blue", "red"] * 2: - await button.click() - assert (await _get_style(button))["background-color"] == color - - -async def _get_style(element): - items = (await element.get_attribute("style")).split(";") - pairs = [item.split(":", 1) for item in map(str.strip, items) if item] - return {key.strip(): value.strip() for key, value in pairs} - - -async def test_slow_server_response_on_input_change(display: DisplayFixture): - """A delay server-side could cause input values to be overwritten. - - For more info see: https://github.com/reactive-python/reactpy/issues/684 - """ - - delay = 0.2 - - @reactpy.component - def SomeComponent(): - value, set_value = reactpy.hooks.use_state("") - - async def handle_change(event): - await asyncio.sleep(delay) - set_value(event["target"]["value"]) - - return reactpy.html.input({"on_change": handle_change, "id": "test-input"}) - - await display.show(SomeComponent) - - inp = await display.page.wait_for_selector("#test-input") - await inp.type("hello", delay=DEFAULT_TYPE_DELAY) - - assert (await inp.evaluate("node => node.value")) == "hello" - - -async def test_snake_case_attributes(display: DisplayFixture): - @reactpy.component - def SomeComponent(): - return reactpy.html.h1( - { - "id": "my-title", - "style": {"background_color": "blue"}, - "class_name": "hello", - "data_some_thing": "some-data", - "aria_some_thing": "some-aria", - }, - "title with some attributes", - ) - - await display.show(SomeComponent) - - title = await display.page.wait_for_selector("#my-title") - - assert await title.get_attribute("class") == "hello" - assert await title.get_attribute("style") == "background-color: blue;" - assert await title.get_attribute("data-some-thing") == "some-data" - assert await title.get_attribute("aria-some-thing") == "some-aria" diff --git a/src/py/reactpy/tests/test_config.py b/src/py/reactpy/tests/test_config.py deleted file mode 100644 index ecbbb998c..000000000 --- a/src/py/reactpy/tests/test_config.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from reactpy import config -from reactpy._option import Option - - -@pytest.fixture(autouse=True) -def reset_options(): - options = [value for value in config.__dict__.values() if isinstance(value, Option)] - - should_unset = object() - original_values = [] - for opt in options: - original_values.append(opt.current if opt.is_set() else should_unset) - - yield - - for opt, val in zip(options, original_values): - if val is should_unset: - if opt.is_set(): - opt.unset() - else: - opt.current = val - - -def test_reactpy_debug_mode_toggle(): - # just check that nothing breaks - config.REACTPY_DEBUG_MODE.current = True - config.REACTPY_DEBUG_MODE.current = False diff --git a/src/py/reactpy/tests/test_core/test_component.py b/src/py/reactpy/tests/test_core/test_component.py deleted file mode 100644 index aa8996d4e..000000000 --- a/src/py/reactpy/tests/test_core/test_component.py +++ /dev/null @@ -1,73 +0,0 @@ -import reactpy -from reactpy.testing import DisplayFixture - - -def test_component_repr(): - @reactpy.component - def MyComponent(a, *b, **c): - pass - - mc1 = MyComponent(1, 2, 3, x=4, y=5) - - expected = f"MyComponent({id(mc1):02x}, a=1, b=(2, 3), c={{'x': 4, 'y': 5}})" - assert repr(mc1) == expected - - # not enough args supplied to function - assert repr(MyComponent()) == "MyComponent(...)" - - -async def test_simple_component(): - @reactpy.component - def SimpleDiv(): - return reactpy.html.div() - - assert SimpleDiv().render() == {"tagName": "div"} - - -async def test_simple_parameterized_component(): - @reactpy.component - def SimpleParamComponent(tag): - return reactpy.vdom(tag) - - assert SimpleParamComponent("div").render() == {"tagName": "div"} - - -async def test_component_with_var_args(): - @reactpy.component - def ComponentWithVarArgsAndKwargs(*args, **kwargs): - return reactpy.html.div(kwargs, args) - - assert ComponentWithVarArgsAndKwargs("hello", "world", my_attr=1).render() == { - "tagName": "div", - "attributes": {"my_attr": 1}, - "children": ["hello", "world"], - } - - -async def test_display_simple_hello_world(display: DisplayFixture): - @reactpy.component - def Hello(): - return reactpy.html.p({"id": "hello"}, ["Hello World"]) - - await display.show(Hello) - - await display.page.wait_for_selector("#hello") - - -async def test_pre_tags_are_rendered_correctly(display: DisplayFixture): - @reactpy.component - def PreFormatted(): - return reactpy.html.pre( - {"id": "pre-form-test"}, - reactpy.html.span("this", reactpy.html.span("is"), "some"), - "pre-formatted", - " text", - ) - - await display.show(PreFormatted) - - pre = await display.page.wait_for_selector("#pre-form-test") - - assert ( - await pre.evaluate("node => node.innerHTML") - ) == "<span>this<span>is</span>some</span>pre-formatted text" diff --git a/src/py/reactpy/tests/test_core/test_events.py b/src/py/reactpy/tests/test_core/test_events.py deleted file mode 100644 index 237c9d4ed..000000000 --- a/src/py/reactpy/tests/test_core/test_events.py +++ /dev/null @@ -1,223 +0,0 @@ -import pytest - -import reactpy -from reactpy.core.events import ( - EventHandler, - merge_event_handler_funcs, - merge_event_handlers, - to_event_handler_function, -) -from reactpy.testing import DisplayFixture, poll -from tests.tooling.common import DEFAULT_TYPE_DELAY - - -def test_event_handler_repr(): - handler = EventHandler(lambda: None) - assert repr(handler) == ( - f"EventHandler(function={handler.function}, prevent_default=False, " - f"stop_propagation=False, target={handler.target!r})" - ) - - -def test_event_handler_props(): - handler_0 = EventHandler(lambda data: None) - assert handler_0.stop_propagation is False - assert handler_0.prevent_default is False - assert handler_0.target is None - - handler_1 = EventHandler(lambda data: None, prevent_default=True) - assert handler_1.stop_propagation is False - assert handler_1.prevent_default is True - assert handler_1.target is None - - handler_2 = EventHandler(lambda data: None, stop_propagation=True) - assert handler_2.stop_propagation is True - assert handler_2.prevent_default is False - assert handler_2.target is None - - handler_3 = EventHandler(lambda data: None, target="123") - assert handler_3.stop_propagation is False - assert handler_3.prevent_default is False - assert handler_3.target == "123" - - -def test_event_handler_equivalence(): - async def func(data): - return None - - assert EventHandler(func) == EventHandler(func) - - assert EventHandler(lambda data: None) != EventHandler(lambda data: None) - - assert EventHandler(func, stop_propagation=True) != EventHandler( - func, stop_propagation=False - ) - - assert EventHandler(func, prevent_default=True) != EventHandler( - func, prevent_default=False - ) - - assert EventHandler(func, target="123") != EventHandler(func, target="456") - - -async def test_to_event_handler_function(): - call_args = reactpy.Ref(None) - - async def coro(*args): - call_args.current = args - - def func(*args): - call_args.current = args - - await to_event_handler_function(coro, positional_args=True)([1, 2, 3]) - assert call_args.current == (1, 2, 3) - - await to_event_handler_function(func, positional_args=True)([1, 2, 3]) - assert call_args.current == (1, 2, 3) - - await to_event_handler_function(coro, positional_args=False)([1, 2, 3]) - assert call_args.current == ([1, 2, 3],) - - await to_event_handler_function(func, positional_args=False)([1, 2, 3]) - assert call_args.current == ([1, 2, 3],) - - -async def test_merge_event_handler_empty_list(): - with pytest.raises(ValueError, match="No event handlers to merge"): - merge_event_handlers([]) - - -@pytest.mark.parametrize( - "kwargs_1, kwargs_2", - [ - ({"stop_propagation": True}, {"stop_propagation": False}), - ({"prevent_default": True}, {"prevent_default": False}), - ({"target": "this"}, {"target": "that"}), - ], -) -async def test_merge_event_handlers_raises_on_mismatch(kwargs_1, kwargs_2): - def func(data): - return None - - with pytest.raises(ValueError, match="Cannot merge handlers"): - merge_event_handlers( - [ - EventHandler(func, **kwargs_1), - EventHandler(func, **kwargs_2), - ] - ) - - -async def test_merge_event_handlers(): - handler = EventHandler(lambda data: None) - assert merge_event_handlers([handler]) is handler - - calls = [] - merged_handler = merge_event_handlers( - [ - EventHandler(lambda data: calls.append("first")), - EventHandler(lambda data: calls.append("second")), - ] - ) - await merged_handler.function({}) - assert calls == ["first", "second"] - - -def test_merge_event_handler_funcs_empty_list(): - with pytest.raises(ValueError, match="No event handler functions to merge"): - merge_event_handler_funcs([]) - - -async def test_merge_event_handler_funcs(): - calls = [] - - async def some_func(data): - calls.append("some_func") - - async def some_other_func(data): - calls.append("some_other_func") - - assert merge_event_handler_funcs([some_func]) is some_func - - merged_handler = merge_event_handler_funcs([some_func, some_other_func]) - await merged_handler([]) - assert calls == ["some_func", "some_other_func"] - - -async def test_can_prevent_event_default_operation(display: DisplayFixture): - @reactpy.component - def Input(): - @reactpy.event(prevent_default=True) - async def on_key_down(value): - pass - - return reactpy.html.input({"on_key_down": on_key_down, "id": "input"}) - - await display.show(Input) - - inp = await display.page.wait_for_selector("#input") - await inp.type("hello", delay=DEFAULT_TYPE_DELAY) - # the default action of updating the element's value did not take place - assert (await inp.evaluate("node => node.value")) == "" - - -async def test_simple_click_event(display: DisplayFixture): - @reactpy.component - def Button(): - clicked, set_clicked = reactpy.hooks.use_state(False) - - async def on_click(event): - set_clicked(True) - - if not clicked: - return reactpy.html.button( - {"on_click": on_click, "id": "click"}, ["Click Me!"] - ) - else: - return reactpy.html.p({"id": "complete"}, ["Complete"]) - - await display.show(Button) - - button = await display.page.wait_for_selector("#click") - await button.click() - await display.page.wait_for_selector("#complete") - - -async def test_can_stop_event_propagation(display: DisplayFixture): - clicked = reactpy.Ref(False) - - @reactpy.component - def DivInDiv(): - @reactpy.event(stop_propagation=True) - def inner_click_no_op(event): - clicked.current = True - - def outer_click_is_not_triggered(event): - raise AssertionError() - - outer = reactpy.html.div( - { - "style": {"height": "35px", "width": "35px", "background_color": "red"}, - "on_click": outer_click_is_not_triggered, - "id": "outer", - }, - reactpy.html.div( - { - "style": { - "height": "30px", - "width": "30px", - "background_color": "blue", - }, - "on_click": inner_click_no_op, - "id": "inner", - } - ), - ) - return outer - - await display.show(DivInDiv) - - inner = await display.page.wait_for_selector("#inner") - await inner.click() - - await poll(lambda: clicked.current).until_is(True) diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py deleted file mode 100644 index 453d07c99..000000000 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ /dev/null @@ -1,1259 +0,0 @@ -import asyncio - -import pytest - -import reactpy -from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.hooks import ( - COMPONENT_DID_RENDER_EFFECT, - LifeCycleHook, - current_hook, - strictly_equal, -) -from reactpy.core.layout import Layout -from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll -from reactpy.testing.logs import assert_reactpy_did_not_log -from reactpy.utils import Ref -from tests.tooling.common import DEFAULT_TYPE_DELAY, update_message - - -async def test_must_be_rendering_in_layout_to_use_hooks(): - @reactpy.component - def SimpleComponentWithHook(): - reactpy.hooks.use_state(None) - return reactpy.html.div() - - with pytest.raises(RuntimeError, match="No life cycle hook is active"): - await SimpleComponentWithHook().render() - - async with reactpy.Layout(SimpleComponentWithHook()) as layout: - await layout.render() - - -async def test_simple_stateful_component(): - @reactpy.component - def SimpleStatefulComponent(): - index, set_index = reactpy.hooks.use_state(0) - set_index(index + 1) - return reactpy.html.div(index) - - sse = SimpleStatefulComponent() - - async with reactpy.Layout(sse) as layout: - update_1 = await layout.render() - assert update_1 == update_message( - path="", - model={ - "tagName": "", - "children": [{"tagName": "div", "children": ["0"]}], - }, - ) - - update_2 = await layout.render() - assert update_2 == update_message( - path="", - model={ - "tagName": "", - "children": [{"tagName": "div", "children": ["1"]}], - }, - ) - - update_3 = await layout.render() - assert update_3 == update_message( - path="", - model={ - "tagName": "", - "children": [{"tagName": "div", "children": ["2"]}], - }, - ) - - -async def test_set_state_callback_identity_is_preserved(): - saved_set_state_hooks = [] - - @reactpy.component - def SimpleStatefulComponent(): - index, set_index = reactpy.hooks.use_state(0) - saved_set_state_hooks.append(set_index) - set_index(index + 1) - return reactpy.html.div(index) - - sse = SimpleStatefulComponent() - - async with reactpy.Layout(sse) as layout: - await layout.render() - await layout.render() - await layout.render() - await layout.render() - - first_hook = saved_set_state_hooks[0] - for h in saved_set_state_hooks[1:]: - assert first_hook is h - - -async def test_use_state_with_constructor(): - constructor_call_count = reactpy.Ref(0) - - set_outer_state = reactpy.Ref() - set_inner_key = reactpy.Ref() - set_inner_state = reactpy.Ref() - - def make_default(): - constructor_call_count.current += 1 - return 0 - - @reactpy.component - def Outer(): - state, set_outer_state.current = reactpy.use_state(0) - inner_key, set_inner_key.current = reactpy.use_state("first") - return reactpy.html.div(state, Inner(key=inner_key)) - - @reactpy.component - def Inner(): - state, set_inner_state.current = reactpy.use_state(make_default) - return reactpy.html.div(state) - - async with reactpy.Layout(Outer()) as layout: - await layout.render() - - assert constructor_call_count.current == 1 - - set_outer_state.current(1) - await layout.render() - - assert constructor_call_count.current == 1 - - set_inner_state.current(1) - await layout.render() - - assert constructor_call_count.current == 1 - - set_inner_key.current("second") - await layout.render() - - assert constructor_call_count.current == 2 - - -async def test_set_state_with_reducer_instead_of_value(): - count = reactpy.Ref() - set_count = reactpy.Ref() - - def increment(count): - return count + 1 - - @reactpy.component - def Counter(): - count.current, set_count.current = reactpy.hooks.use_state(0) - return reactpy.html.div(count.current) - - async with reactpy.Layout(Counter()) as layout: - await layout.render() - - for i in range(4): - assert count.current == i - set_count.current(increment) - await layout.render() - - -async def test_set_state_checks_identity_not_equality(display: DisplayFixture): - r_1 = reactpy.Ref("value") - r_2 = reactpy.Ref("value") - - # refs are equal but not identical - assert r_1 == r_2 - assert r_1 is not r_2 - - render_count = reactpy.Ref(0) - event_count = reactpy.Ref(0) - - def event_count_tracker(function): - def tracker(*args, **kwargs): - event_count.current += 1 - return function(*args, **kwargs) - - return tracker - - @reactpy.component - def TestComponent(): - state, set_state = reactpy.hooks.use_state(r_1) - - render_count.current += 1 - return reactpy.html.div( - reactpy.html.button( - { - "id": "r_1", - "on_click": event_count_tracker(lambda event: set_state(r_1)), - }, - "r_1", - ), - reactpy.html.button( - { - "id": "r_2", - "on_click": event_count_tracker(lambda event: set_state(r_2)), - }, - "r_2", - ), - f"Last state: {'r_1' if state is r_1 else 'r_2'}", - ) - - await display.show(TestComponent) - - client_r_1_button = await display.page.wait_for_selector("#r_1") - client_r_2_button = await display.page.wait_for_selector("#r_2") - - poll_event_count = poll(lambda: event_count.current) - poll_render_count = poll(lambda: render_count.current) - - assert render_count.current == 1 - assert event_count.current == 0 - - await client_r_1_button.click() - - await poll_event_count.until_equals(1) - await poll_render_count.until_equals(1) - - await client_r_2_button.click() - - await poll_event_count.until_equals(2) - await poll_render_count.until_equals(2) - - await client_r_2_button.click() - - await poll_event_count.until_equals(3) - await poll_render_count.until_equals(2) - - -async def test_simple_input_with_use_state(display: DisplayFixture): - message_ref = reactpy.Ref(None) - - @reactpy.component - def Input(message=None): - message, set_message = reactpy.hooks.use_state(message) - message_ref.current = message - - async def on_change(event): - if event["target"]["value"] == "this is a test": - set_message(event["target"]["value"]) - - if message is None: - return reactpy.html.input({"id": "input", "on_change": on_change}) - else: - return reactpy.html.p({"id": "complete"}, ["Complete"]) - - await display.show(Input) - - button = await display.page.wait_for_selector("#input") - await button.type("this is a test", delay=DEFAULT_TYPE_DELAY) - await display.page.wait_for_selector("#complete") - - assert message_ref.current == "this is a test" - - -async def test_double_set_state(display: DisplayFixture): - @reactpy.component - def SomeComponent(): - state_1, set_state_1 = reactpy.hooks.use_state(0) - state_2, set_state_2 = reactpy.hooks.use_state(0) - - def double_set_state(event): - set_state_1(state_1 + 1) - set_state_2(state_2 + 1) - - return reactpy.html.div( - reactpy.html.div( - {"id": "first", "data-value": state_1}, f"value is: {state_1}" - ), - reactpy.html.div( - {"id": "second", "data-value": state_2}, f"value is: {state_2}" - ), - reactpy.html.button( - {"id": "button", "on_click": double_set_state}, "click me" - ), - ) - - await display.show(SomeComponent) - - button = await display.page.wait_for_selector("#button") - first = await display.page.wait_for_selector("#first") - second = await display.page.wait_for_selector("#second") - - assert (await first.get_attribute("data-value")) == "0" - assert (await second.get_attribute("data-value")) == "0" - - await button.click() - - assert (await first.get_attribute("data-value")) == "1" - assert (await second.get_attribute("data-value")) == "1" - - await button.click() - - assert (await first.get_attribute("data-value")) == "2" - assert (await second.get_attribute("data-value")) == "2" - - -async def test_use_effect_callback_occurs_after_full_render_is_complete(): - effect_triggered = reactpy.Ref(False) - effect_triggers_after_final_render = reactpy.Ref(None) - - @reactpy.component - def OuterComponent(): - return reactpy.html.div( - ComponentWithEffect(), - CheckNoEffectYet(), - ) - - @reactpy.component - def ComponentWithEffect(): - @reactpy.hooks.use_effect - def effect(): - effect_triggered.current = True - - return reactpy.html.div() - - @reactpy.component - def CheckNoEffectYet(): - effect_triggers_after_final_render.current = not effect_triggered.current - return reactpy.html.div() - - async with reactpy.Layout(OuterComponent()) as layout: - await layout.render() - - assert effect_triggered.current - assert effect_triggers_after_final_render.current is not None - assert effect_triggers_after_final_render.current - - -async def test_use_effect_cleanup_occurs_before_next_effect(): - component_hook = HookCatcher() - cleanup_triggered = reactpy.Ref(False) - cleanup_triggered_before_next_effect = reactpy.Ref(False) - - @reactpy.component - @component_hook.capture - def ComponentWithEffect(): - @reactpy.hooks.use_effect(dependencies=None) - def effect(): - if cleanup_triggered.current: - cleanup_triggered_before_next_effect.current = True - - def cleanup(): - cleanup_triggered.current = True - - return cleanup - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() - - assert not cleanup_triggered.current - - component_hook.latest.schedule_render() - await layout.render() - - assert cleanup_triggered.current - assert cleanup_triggered_before_next_effect.current - - -async def test_use_effect_cleanup_occurs_on_will_unmount(): - set_key = reactpy.Ref() - component_did_render = reactpy.Ref(False) - cleanup_triggered = reactpy.Ref(False) - cleanup_triggered_before_next_render = reactpy.Ref(False) - - @reactpy.component - def OuterComponent(): - key, set_key.current = reactpy.use_state("first") - return ComponentWithEffect(key=key) - - @reactpy.component - def ComponentWithEffect(): - if component_did_render.current and cleanup_triggered.current: - cleanup_triggered_before_next_render.current = True - - component_did_render.current = True - - @reactpy.hooks.use_effect - def effect(): - def cleanup(): - cleanup_triggered.current = True - - return cleanup - - return reactpy.html.div() - - async with reactpy.Layout(OuterComponent()) as layout: - await layout.render() - - assert not cleanup_triggered.current - - set_key.current("second") - await layout.render() - - assert cleanup_triggered.current - assert cleanup_triggered_before_next_render.current - - -async def test_memoized_effect_on_recreated_if_dependencies_change(): - component_hook = HookCatcher() - set_state_callback = reactpy.Ref(None) - effect_run_count = reactpy.Ref(0) - - first_value = 1 - second_value = 2 - - @reactpy.component - @component_hook.capture - def ComponentWithMemoizedEffect(): - state, set_state_callback.current = reactpy.hooks.use_state(first_value) - - @reactpy.hooks.use_effect(dependencies=[state]) - def effect(): - effect_run_count.current += 1 - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithMemoizedEffect()) as layout: - await layout.render() - - assert effect_run_count.current == 1 - - component_hook.latest.schedule_render() - await layout.render() - - assert effect_run_count.current == 1 - - set_state_callback.current(second_value) - await layout.render() - - assert effect_run_count.current == 2 - - component_hook.latest.schedule_render() - await layout.render() - - assert effect_run_count.current == 2 - - -async def test_memoized_effect_cleanup_only_triggered_before_new_effect(): - component_hook = HookCatcher() - set_state_callback = reactpy.Ref(None) - cleanup_trigger_count = reactpy.Ref(0) - - first_value = 1 - second_value = 2 - - @reactpy.component - @component_hook.capture - def ComponentWithEffect(): - state, set_state_callback.current = reactpy.hooks.use_state(first_value) - - @reactpy.hooks.use_effect(dependencies=[state]) - def effect(): - def cleanup(): - cleanup_trigger_count.current += 1 - - return cleanup - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() - - assert cleanup_trigger_count.current == 0 - - component_hook.latest.schedule_render() - await layout.render() - - assert cleanup_trigger_count.current == 0 - - set_state_callback.current(second_value) - await layout.render() - - assert cleanup_trigger_count.current == 1 - - -async def test_use_async_effect(): - effect_ran = asyncio.Event() - - @reactpy.component - def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect - async def effect(): - effect_ran.set() - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - await layout.render() - await asyncio.wait_for(effect_ran.wait(), 1) - - -async def test_use_async_effect_cleanup(): - component_hook = HookCatcher() - effect_ran = asyncio.Event() - cleanup_ran = asyncio.Event() - - @reactpy.component - @component_hook.capture - def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time - async def effect(): - effect_ran.set() - return cleanup_ran.set - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: - await layout.render() - - component_hook.latest.schedule_render() - - await layout.render() - - await asyncio.wait_for(cleanup_ran.wait(), 1) - - -async def test_use_async_effect_cancel(caplog): - component_hook = HookCatcher() - effect_ran = asyncio.Event() - effect_was_cancelled = asyncio.Event() - - event_that_never_occurs = asyncio.Event() - - @reactpy.component - @component_hook.capture - def ComponentWithLongWaitingEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time - async def effect(): - effect_ran.set() - try: - await event_that_never_occurs.wait() - except asyncio.CancelledError: - effect_was_cancelled.set() - raise - - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithLongWaitingEffect()) as layout: - await layout.render() - - await effect_ran.wait() - component_hook.latest.schedule_render() - - await layout.render() - - await asyncio.wait_for(effect_was_cancelled.wait(), 1) - - # So I know we said the event never occurs but... to ensure the effect's future is - # cancelled before the test is cleaned up we need to set the event. This is because - # the cancellation doesn't propagate before the test is resolved which causes - # delayed log messages that impact other tests. - event_that_never_occurs.set() - - -async def test_error_in_effect_is_gracefully_handled(caplog): - @reactpy.component - def ComponentWithEffect(): - @reactpy.hooks.use_effect - def bad_effect(): - msg = "Something went wong :(" - raise ValueError(msg) - - return reactpy.html.div() - - with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"): - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() # no error - - -async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled(): - set_key = reactpy.Ref() - - @reactpy.component - def OuterComponent(): - key, set_key.current = reactpy.use_state("first") - return ComponentWithEffect(key=key) - - @reactpy.component - def ComponentWithEffect(): - @reactpy.hooks.use_effect - def ok_effect(): - def bad_cleanup(): - msg = "Something went wong :(" - raise ValueError(msg) - - return bad_cleanup - - return reactpy.html.div() - - with assert_reactpy_did_log( - match_message=r"Pre-unmount effect .*? failed", - error_type=ValueError, - ): - async with reactpy.Layout(OuterComponent()) as layout: - await layout.render() - set_key.current("second") - await layout.render() # no error - - -async def test_use_reducer(): - saved_count = reactpy.Ref(None) - saved_dispatch = reactpy.Ref(None) - - def reducer(count, action): - if action == "increment": - return count + 1 - elif action == "decrement": - return count - 1 - else: - msg = f"Unknown action '{action}'" - raise ValueError(msg) - - @reactpy.component - def Counter(initial_count): - saved_count.current, saved_dispatch.current = reactpy.hooks.use_reducer( - reducer, initial_count - ) - return reactpy.html.div() - - async with reactpy.Layout(Counter(0)) as layout: - await layout.render() - - assert saved_count.current == 0 - - saved_dispatch.current("increment") - await layout.render() - - assert saved_count.current == 1 - - saved_dispatch.current("decrement") - await layout.render() - - assert saved_count.current == 0 - - -async def test_use_reducer_dispatch_callback_identity_is_preserved(): - saved_dispatchers = [] - - def reducer(count, action): - if action == "increment": - return count + 1 - else: - msg = f"Unknown action '{action}'" - raise ValueError(msg) - - @reactpy.component - def ComponentWithUseReduce(): - saved_dispatchers.append(reactpy.hooks.use_reducer(reducer, 0)[1]) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithUseReduce()) as layout: - for _ in range(3): - await layout.render() - saved_dispatchers[-1]("increment") - - first_dispatch = saved_dispatchers[0] - for d in saved_dispatchers[1:]: - assert first_dispatch is d - - -async def test_use_callback_identity(): - component_hook = HookCatcher() - used_callbacks = [] - - @reactpy.component - @component_hook.capture - def ComponentWithRef(): - used_callbacks.append(reactpy.hooks.use_callback(lambda: None)) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithRef()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - - assert used_callbacks[0] is used_callbacks[1] - assert len(used_callbacks) == 2 - - -async def test_use_callback_memoization(): - component_hook = HookCatcher() - set_state_hook = reactpy.Ref(None) - used_callbacks = [] - - @reactpy.component - @component_hook.capture - def ComponentWithRef(): - state, set_state_hook.current = reactpy.hooks.use_state(0) - - @reactpy.hooks.use_callback( - dependencies=[state] - ) # use the deco form for coverage - def cb(): - return None - - used_callbacks.append(cb) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithRef()) as layout: - await layout.render() - set_state_hook.current(1) - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - - assert used_callbacks[0] is not used_callbacks[1] - assert used_callbacks[1] is used_callbacks[2] - assert len(used_callbacks) == 3 - - -async def test_use_memo(): - component_hook = HookCatcher() - set_state_hook = reactpy.Ref(None) - used_values = [] - - @reactpy.component - @component_hook.capture - def ComponentWithMemo(): - state, set_state_hook.current = reactpy.hooks.use_state(0) - value = reactpy.hooks.use_memo( - lambda: reactpy.Ref( - state - ), # use a Ref here just to ensure it's a unique obj - [state], - ) - used_values.append(value) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithMemo()) as layout: - await layout.render() - set_state_hook.current(1) - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - - assert used_values[0] is not used_values[1] - assert used_values[1] is used_values[2] - assert len(used_values) == 3 - - -async def test_use_memo_always_runs_if_dependencies_are_none(): - component_hook = HookCatcher() - used_values = [] - - iter_values = iter([1, 2, 3]) - - @reactpy.component - @component_hook.capture - def ComponentWithMemo(): - value = reactpy.hooks.use_memo(lambda: next(iter_values), dependencies=None) - used_values.append(value) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithMemo()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - - assert used_values == [1, 2, 3] - - -async def test_use_memo_with_stored_deps_is_empty_tuple_after_deps_are_none(): - component_hook = HookCatcher() - used_values = [] - - iter_values = iter([1, 2, 3]) - deps_used_in_memo = reactpy.Ref(()) - - @reactpy.component - @component_hook.capture - def ComponentWithMemo(): - value = reactpy.hooks.use_memo( - lambda: next(iter_values), - deps_used_in_memo.current, - ) - used_values.append(value) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithMemo()) as layout: - await layout.render() - component_hook.latest.schedule_render() - deps_used_in_memo.current = None - await layout.render() - component_hook.latest.schedule_render() - deps_used_in_memo.current = () - await layout.render() - - assert used_values == [1, 2, 2] - - -async def test_use_memo_never_runs_if_deps_is_empty_list(): - component_hook = HookCatcher() - used_values = [] - - iter_values = iter([1, 2, 3]) - - @reactpy.component - @component_hook.capture - def ComponentWithMemo(): - value = reactpy.hooks.use_memo(lambda: next(iter_values), ()) - used_values.append(value) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithMemo()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - - assert used_values == [1, 1, 1] - - -async def test_use_ref(): - component_hook = HookCatcher() - used_refs = [] - - @reactpy.component - @component_hook.capture - def ComponentWithRef(): - used_refs.append(reactpy.hooks.use_ref(1)) - return reactpy.html.div() - - async with reactpy.Layout(ComponentWithRef()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() - - assert used_refs[0] is used_refs[1] - assert len(used_refs) == 2 - - -def test_bad_schedule_render_callback(): - def bad_callback(): - msg = "something went wrong" - raise ValueError(msg) - - with assert_reactpy_did_log( - match_message=f"Failed to schedule render via {bad_callback}" - ): - LifeCycleHook(bad_callback).schedule_render() - - -async def test_use_effect_automatically_infers_closure_values(): - set_count = reactpy.Ref() - did_effect = asyncio.Event() - - @reactpy.component - def CounterWithEffect(): - count, set_count.current = reactpy.hooks.use_state(0) - - @reactpy.hooks.use_effect - def some_effect_that_uses_count(): - """should automatically trigger on count change""" - _ = count # use count in this closure - did_effect.set() - - return reactpy.html.div() - - async with reactpy.Layout(CounterWithEffect()) as layout: - await layout.render() - await did_effect.wait() - did_effect.clear() - - for i in range(1, 3): - set_count.current(i) - await layout.render() - await did_effect.wait() - did_effect.clear() - - -async def test_use_memo_automatically_infers_closure_values(): - set_count = reactpy.Ref() - did_memo = asyncio.Event() - - @reactpy.component - def CounterWithEffect(): - count, set_count.current = reactpy.hooks.use_state(0) - - @reactpy.hooks.use_memo - def some_memo_func_that_uses_count(): - """should automatically trigger on count change""" - _ = count # use count in this closure - did_memo.set() - - return reactpy.html.div() - - async with reactpy.Layout(CounterWithEffect()) as layout: - await layout.render() - await did_memo.wait() - did_memo.clear() - - for i in range(1, 3): - set_count.current(i) - await layout.render() - await did_memo.wait() - did_memo.clear() - - -async def test_use_context_default_value(): - Context = reactpy.create_context("something") - value = reactpy.Ref() - - @reactpy.component - def ComponentProvidesContext(): - return Context(ComponentUsesContext()) - - @reactpy.component - def ComponentUsesContext(): - value.current = reactpy.use_context(Context) - return html.div() - - async with reactpy.Layout(ComponentProvidesContext()) as layout: - await layout.render() - assert value.current == "something" - - @reactpy.component - def ComponentUsesContext2(): - value.current = reactpy.use_context(Context) - return html.div() - - async with reactpy.Layout(ComponentUsesContext2()) as layout: - await layout.render() - assert value.current == "something" - - -def test_context_repr(): - sample_context = reactpy.create_context(None) - assert repr(sample_context()) == f"ContextProvider({sample_context})" - - -async def test_use_context_updates_components_even_if_memoized(): - Context = reactpy.create_context(None) - - value = reactpy.Ref(None) - render_count = reactpy.Ref(0) - set_state = reactpy.Ref() - - @reactpy.component - def ComponentProvidesContext(): - state, set_state.current = reactpy.use_state(0) - return Context(ComponentInContext(), value=state) - - @reactpy.component - def ComponentInContext(): - return reactpy.use_memo(MemoizedComponentUsesContext) - - @reactpy.component - def MemoizedComponentUsesContext(): - value.current = reactpy.use_context(Context) - render_count.current += 1 - return html.div() - - async with reactpy.Layout(ComponentProvidesContext()) as layout: - await layout.render() - assert render_count.current == 1 - assert value.current == 0 - - set_state.current(1) - - await layout.render() - assert render_count.current == 2 - assert value.current == 1 - - set_state.current(2) - - await layout.render() - assert render_count.current == 3 - assert value.current == 2 - - -async def test_context_values_are_scoped(): - Context = reactpy.create_context(None) - - @reactpy.component - def Parent(): - return html._( - Context(Context(Child1(), value=1), value="something-else"), - Context(Child2(), value=2), - ) - - @reactpy.component - def Child1(): - assert reactpy.use_context(Context) == 1 - - @reactpy.component - def Child2(): - assert reactpy.use_context(Context) == 2 - - async with Layout(Parent()) as layout: - await layout.render() - - -async def test_error_in_layout_effect_cleanup_is_gracefully_handled(): - component_hook = HookCatcher() - - @reactpy.component - @component_hook.capture - def ComponentWithEffect(): - @reactpy.hooks.use_effect(dependencies=None) # always run - def bad_effect(): - msg = "The error message" - raise ValueError(msg) - - return reactpy.html.div() - - with assert_reactpy_did_log( - match_message=r"post-render effect .*? failed", - error_type=ValueError, - match_error="The error message", - ): - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() # no error - - -async def test_set_state_during_render(): - render_count = Ref(0) - - @reactpy.component - def SetStateDuringRender(): - render_count.current += 1 - state, set_state = reactpy.use_state(0) - if not state: - set_state(state + 1) - return html.div(state) - - async with Layout(SetStateDuringRender()) as layout: - await layout.render() - assert render_count.current == 1 - await layout.render() - assert render_count.current == 2 - - # there should be no more renders to perform - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(layout.render(), timeout=0.1) - - -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") -async def test_use_debug_mode(): - set_message = reactpy.Ref() - component_hook = HookCatcher() - - @reactpy.component - @component_hook.capture - def SomeComponent(): - message, set_message.current = reactpy.use_state("hello") - reactpy.use_debug_value(f"message is {message!r}") - return reactpy.html.div() - - async with reactpy.Layout(SomeComponent()) as layout: - with assert_reactpy_did_log(r"SomeComponent\(.*?\) message is 'hello'"): - await layout.render() - - set_message.current("bye") - - with assert_reactpy_did_log(r"SomeComponent\(.*?\) message is 'bye'"): - await layout.render() - - component_hook.latest.schedule_render() - - with assert_reactpy_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"): - await layout.render() - - -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") -async def test_use_debug_mode_with_factory(): - set_message = reactpy.Ref() - component_hook = HookCatcher() - - @reactpy.component - @component_hook.capture - def SomeComponent(): - message, set_message.current = reactpy.use_state("hello") - reactpy.use_debug_value(lambda: f"message is {message!r}") - return reactpy.html.div() - - async with reactpy.Layout(SomeComponent()) as layout: - with assert_reactpy_did_log(r"SomeComponent\(.*?\) message is 'hello'"): - await layout.render() - - set_message.current("bye") - - with assert_reactpy_did_log(r"SomeComponent\(.*?\) message is 'bye'"): - await layout.render() - - component_hook.latest.schedule_render() - - with assert_reactpy_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"): - await layout.render() - - -@pytest.mark.skipif(REACTPY_DEBUG_MODE.current, reason="logs in debug mode") -async def test_use_debug_mode_does_not_log_if_not_in_debug_mode(): - set_message = reactpy.Ref() - - @reactpy.component - def SomeComponent(): - message, set_message.current = reactpy.use_state("hello") - reactpy.use_debug_value(lambda: f"message is {message!r}") - return reactpy.html.div() - - async with reactpy.Layout(SomeComponent()) as layout: - with assert_reactpy_did_not_log(r"SomeComponent\(.*?\) message is 'hello'"): - await layout.render() - - set_message.current("bye") - - with assert_reactpy_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"): - await layout.render() - - -async def test_conditionally_rendered_components_can_use_context(): - set_state = reactpy.Ref() - used_context_values = [] - some_context = reactpy.create_context(None) - - @reactpy.component - def SomeComponent(): - state, set_state.current = reactpy.use_state(True) - if state: - return FirstCondition() - else: - return SecondCondition() - - @reactpy.component - def FirstCondition(): - used_context_values.append(reactpy.use_context(some_context) + "-1") - - @reactpy.component - def SecondCondition(): - used_context_values.append(reactpy.use_context(some_context) + "-2") - - async with reactpy.Layout( - some_context(SomeComponent(), value="the-value") - ) as layout: - await layout.render() - assert used_context_values == ["the-value-1"] - set_state.current(False) - await layout.render() - assert used_context_values == ["the-value-1", "the-value-2"] - - -@pytest.mark.parametrize( - "x, y, result", - [ - ("text", "text", True), - ("text", "not-text", False), - (b"text", b"text", True), - (b"text", b"not-text", False), - (bytearray([1, 2, 3]), bytearray([1, 2, 3]), True), - (bytearray([1, 2, 3]), bytearray([1, 2, 3, 4]), False), - (1.0, 1.0, True), - (1.0, 2.0, False), - (1j, 1j, True), - (1j, 2j, False), - # ints less than 5 and greater than 256 are always identical - (-100000, -100000, True), - (100000, 100000, True), - (123, 456, False), - ], -) -def test_strictly_equal(x, y, result): - assert strictly_equal(x, y) is result - - -STRICT_EQUALITY_VALUE_CONSTRUCTORS = [ - lambda: "string-text", - lambda: b"byte-text", - lambda: bytearray([1, 2, 3]), - lambda: bytearray([1, 2, 3]), - lambda: 1.0, - lambda: 10000000, - lambda: 1j, -] - - -@pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS) -async def test_use_state_compares_with_strict_equality(get_value): - render_count = reactpy.Ref(0) - set_state = reactpy.Ref() - - @reactpy.component - def SomeComponent(): - _, set_state.current = reactpy.use_state(get_value()) - render_count.current += 1 - - async with reactpy.Layout(SomeComponent()) as layout: - await layout.render() - assert render_count.current == 1 - set_state.current(get_value()) - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(layout.render(), timeout=0.1) - - -@pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS) -async def test_use_effect_compares_with_strict_equality(get_value): - effect_count = reactpy.Ref(0) - value = reactpy.Ref("string") - hook = HookCatcher() - - @reactpy.component - @hook.capture - def SomeComponent(): - @reactpy.use_effect(dependencies=[value.current]) - def incr_effect_count(): - effect_count.current += 1 - - async with reactpy.Layout(SomeComponent()) as layout: - await layout.render() - assert effect_count.current == 1 - value.current = "string" # new string instance but same value - hook.latest.schedule_render() - await layout.render() - # effect does not trigger - assert effect_count.current == 1 - - -async def test_use_state_named_tuple(): - state = reactpy.Ref() - - @reactpy.component - def some_component(): - state.current = reactpy.use_state(1) - - async with reactpy.Layout(some_component()) as layout: - await layout.render() - assert state.current.value == 1 - state.current.set_value(2) - await layout.render() - assert state.current.value == 2 - - -async def test_error_in_component_effect_cleanup_is_gracefully_handled(): - component_hook = HookCatcher() - - @reactpy.component - @component_hook.capture - def ComponentWithEffect(): - hook = current_hook() - - def bad_effect(): - raise ValueError("The error message") - - hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) - return reactpy.html.div() - - with assert_reactpy_did_log( - match_message="Component post-render effect .*? failed", - error_type=ValueError, - match_error="The error message", - ): - async with reactpy.Layout(ComponentWithEffect()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() # no error diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py deleted file mode 100644 index d2e1a8099..000000000 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ /dev/null @@ -1,1192 +0,0 @@ -import asyncio -import gc -import random -import re -from weakref import finalize -from weakref import ref as weakref - -import pytest - -import reactpy -from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.component import component -from reactpy.core.hooks import use_effect, use_state -from reactpy.core.layout import Layout -from reactpy.testing import ( - HookCatcher, - StaticEventHandler, - assert_reactpy_did_log, - capture_reactpy_logs, -) -from reactpy.utils import Ref -from tests.tooling.common import event_message, update_message -from tests.tooling.hooks import use_force_render, use_toggle - - -@pytest.fixture(autouse=True) -def no_logged_errors(): - with capture_reactpy_logs() as logs: - yield - for record in logs: - if record.exc_info: - raise record.exc_info[1] - - -def test_layout_repr(): - @reactpy.component - def MyComponent(): - ... - - my_component = MyComponent() - layout = reactpy.Layout(my_component) - assert str(layout) == f"Layout(MyComponent({id(my_component):02x}))" - - -def test_layout_expects_abstract_component(): - with pytest.raises(TypeError, match="Expected a ComponentType"): - reactpy.Layout(None) - with pytest.raises(TypeError, match="Expected a ComponentType"): - reactpy.Layout(reactpy.html.div()) - - -async def test_layout_cannot_be_used_outside_context_manager(caplog): - @reactpy.component - def Component(): - ... - - component = Component() - layout = reactpy.Layout(component) - - with pytest.raises(AttributeError): - await layout.deliver(event_message("something")) - - with pytest.raises(AttributeError): - await layout.render() - - -async def test_simple_layout(): - set_state_hook = reactpy.Ref() - - @reactpy.component - def SimpleComponent(): - tag, set_state_hook.current = reactpy.hooks.use_state("div") - return reactpy.vdom(tag) - - async with reactpy.Layout(SimpleComponent()) as layout: - update_1 = await layout.render() - assert update_1 == update_message( - path="", - model={"tagName": "", "children": [{"tagName": "div"}]}, - ) - - set_state_hook.current("table") - - update_2 = await layout.render() - assert update_2 == update_message( - path="", - model={"tagName": "", "children": [{"tagName": "table"}]}, - ) - - -async def test_component_can_return_none(): - @reactpy.component - def SomeComponent(): - return None - - async with reactpy.Layout(SomeComponent()) as layout: - assert (await layout.render())["model"] == {"tagName": ""} - - -async def test_nested_component_layout(): - parent_set_state = reactpy.Ref(None) - child_set_state = reactpy.Ref(None) - - @reactpy.component - def Parent(): - state, parent_set_state.current = reactpy.hooks.use_state(0) - return reactpy.html.div(state, Child()) - - @reactpy.component - def Child(): - state, child_set_state.current = reactpy.hooks.use_state(0) - return reactpy.html.div(state) - - def make_parent_model(state, model): - return { - "tagName": "", - "children": [ - { - "tagName": "div", - "children": [str(state), model], - } - ], - } - - def make_child_model(state): - return { - "tagName": "", - "children": [{"tagName": "div", "children": [str(state)]}], - } - - async with reactpy.Layout(Parent()) as layout: - update_1 = await layout.render() - assert update_1 == update_message( - path="", - model=make_parent_model(0, make_child_model(0)), - ) - - parent_set_state.current(1) - - update_2 = await layout.render() - assert update_2 == update_message( - path="", - model=make_parent_model(1, make_child_model(0)), - ) - - child_set_state.current(1) - - update_3 = await layout.render() - assert update_3 == update_message( - path="/children/0/children/1", - model=make_child_model(1), - ) - - -@pytest.mark.skipif( - not REACTPY_DEBUG_MODE.current, - reason="errors only reported in debug mode", -) -async def test_layout_render_error_has_partial_update_with_error_message(): - @reactpy.component - def Main(): - return reactpy.html.div([OkChild(), BadChild(), OkChild()]) - - @reactpy.component - def OkChild(): - return reactpy.html.div(["hello"]) - - @reactpy.component - def BadChild(): - msg = "error from bad child" - raise ValueError(msg) - - with assert_reactpy_did_log(match_error="error from bad child"): - async with reactpy.Layout(Main()) as layout: - assert (await layout.render()) == update_message( - path="", - model={ - "tagName": "", - "children": [ - { - "tagName": "div", - "children": [ - { - "tagName": "", - "children": [ - {"tagName": "div", "children": ["hello"]} - ], - }, - { - "tagName": "", - "error": "ValueError: error from bad child", - }, - { - "tagName": "", - "children": [ - {"tagName": "div", "children": ["hello"]} - ], - }, - ], - } - ], - }, - ) - - -@pytest.mark.skipif( - REACTPY_DEBUG_MODE.current, - reason="errors only reported in debug mode", -) -async def test_layout_render_error_has_partial_update_without_error_message(): - @reactpy.component - def Main(): - return reactpy.html.div([OkChild(), BadChild(), OkChild()]) - - @reactpy.component - def OkChild(): - return reactpy.html.div(["hello"]) - - @reactpy.component - def BadChild(): - msg = "error from bad child" - raise ValueError(msg) - - with assert_reactpy_did_log(match_error="error from bad child"): - async with reactpy.Layout(Main()) as layout: - assert (await layout.render()) == update_message( - path="", - model={ - "tagName": "", - "children": [ - { - "children": [ - { - "children": [ - {"children": ["hello"], "tagName": "div"} - ], - "tagName": "", - }, - {"error": "", "tagName": ""}, - { - "children": [ - {"children": ["hello"], "tagName": "div"} - ], - "tagName": "", - }, - ], - "tagName": "div", - } - ], - }, - ) - - -async def test_render_raw_vdom_dict_with_single_component_object_as_children(): - @reactpy.component - def Main(): - return {"tagName": "div", "children": Child()} - - @reactpy.component - def Child(): - return {"tagName": "div", "children": {"tagName": "h1"}} - - async with reactpy.Layout(Main()) as layout: - assert (await layout.render()) == update_message( - path="", - model={ - "tagName": "", - "children": [ - { - "children": [ - { - "children": [ - { - "children": [{"tagName": "h1"}], - "tagName": "div", - } - ], - "tagName": "", - } - ], - "tagName": "div", - } - ], - }, - ) - - -async def test_components_are_garbage_collected(): - live_components = set() - outer_component_hook = HookCatcher() - - def add_to_live_components(constructor): - def wrapper(*args, **kwargs): - component = constructor(*args, **kwargs) - component_id = id(component) - live_components.add(component_id) - finalize(component, live_components.discard, component_id) - return component - - return wrapper - - @add_to_live_components - @reactpy.component - @outer_component_hook.capture - def Outer(): - return Inner() - - @add_to_live_components - @reactpy.component - def Inner(): - return reactpy.html.div() - - async with reactpy.Layout(Outer()) as layout: - await layout.render() - - assert len(live_components) == 2 - - last_live_components = live_components.copy() - # The existing `Outer` component rerenders. A new `Inner` component is created and - # the the old `Inner` component should be deleted. Thus there should be one - # changed component in the set of `live_components` the old `Inner` deleted and new - # `Inner` added. - outer_component_hook.latest.schedule_render() - await layout.render() - - assert len(live_components - last_live_components) == 1 - - # The layout still holds a reference to the root so that's - # only deleted once we release our reference to the layout. - del layout - # the hook also contains a reference to the root component - del outer_component_hook - - assert not live_components - - -async def test_root_component_life_cycle_hook_is_garbage_collected(): - live_hooks = set() - - def add_to_live_hooks(constructor): - def wrapper(*args, **kwargs): - result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() - hook_id = id(hook) - live_hooks.add(hook_id) - finalize(hook, live_hooks.discard, hook_id) - return result - - return wrapper - - @reactpy.component - @add_to_live_hooks - def Root(): - return reactpy.html.div() - - async with reactpy.Layout(Root()) as layout: - await layout.render() - - assert len(live_hooks) == 1 - - # The layout still holds a reference to the root so that's only deleted once we - # release our reference to the layout. - del layout - - assert not live_hooks - - -async def test_life_cycle_hooks_are_garbage_collected(): - live_hooks = set() - set_inner_component = None - - def add_to_live_hooks(constructor): - def wrapper(*args, **kwargs): - result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() - hook_id = id(hook) - live_hooks.add(hook_id) - finalize(hook, live_hooks.discard, hook_id) - return result - - return wrapper - - @reactpy.component - @add_to_live_hooks - def Outer(): - nonlocal set_inner_component - inner_component, set_inner_component = reactpy.hooks.use_state( - Inner(key="first") - ) - return inner_component - - @reactpy.component - @add_to_live_hooks - def Inner(): - return reactpy.html.div() - - async with reactpy.Layout(Outer()) as layout: - await layout.render() - - assert len(live_hooks) == 2 - last_live_hooks = live_hooks.copy() - - # We expect the hook for `InnerOne` to be garbage collected since the component - # will get replaced. - set_inner_component(Inner(key="second")) - await layout.render() - assert len(live_hooks - last_live_hooks) == 1 - - # The layout still holds a reference to the root so that's only deleted once we - # release our reference to the layout. - del layout - del set_inner_component - - # For some reason, holding `set_inner_component` outside the render context causes - # the associated hook to not be automatically garbage collected. After some - # empirical investigation, it seems that if we do not hold `set_inner_component` in - # this way, the call to `gc.collect()` isn't required. This is demonstrated in - # `test_root_component_life_cycle_hook_is_garbage_collected` - gc.collect() - - assert not live_hooks - - -async def test_double_updated_component_is_not_double_rendered(): - hook = HookCatcher() - run_count = reactpy.Ref(0) - - @reactpy.component - @hook.capture - def AnyComponent(): - run_count.current += 1 - return reactpy.html.div() - - async with reactpy.Layout(AnyComponent()) as layout: - await layout.render() - - assert run_count.current == 1 - - hook.latest.schedule_render() - hook.latest.schedule_render() - - await layout.render() - try: - await asyncio.wait_for( - layout.render(), - timeout=0.1, # this should have been plenty of time - ) - except asyncio.TimeoutError: - pass # the render should still be rendering since we only update once - - assert run_count.current == 2 - - -async def test_update_path_to_component_that_is_not_direct_child_is_correct(): - hook = HookCatcher() - - @reactpy.component - def Parent(): - return reactpy.html.div(reactpy.html.div(Child())) - - @reactpy.component - @hook.capture - def Child(): - return reactpy.html.div() - - async with reactpy.Layout(Parent()) as layout: - await layout.render() - - hook.latest.schedule_render() - - update = await layout.render() - assert update["path"] == "/children/0/children/0/children/0" - - -async def test_log_on_dispatch_to_missing_event_handler(caplog): - @reactpy.component - def SomeComponent(): - return reactpy.html.div() - - async with reactpy.Layout(SomeComponent()) as layout: - await layout.deliver(event_message("missing")) - - assert re.match( - "Ignored event - handler 'missing' does not exist or its component unmounted", - next(iter(caplog.records)).msg, - ) - - -async def test_model_key_preserves_callback_identity_for_common_elements(caplog): - called_good_trigger = reactpy.Ref(False) - good_handler = StaticEventHandler() - bad_handler = StaticEventHandler() - - @reactpy.component - def MyComponent(): - reverse_children, set_reverse_children = use_toggle() - - @good_handler.use - def good_trigger(): - called_good_trigger.current = True - set_reverse_children() - - @bad_handler.use - def bad_trigger(): - msg = "Called bad trigger" - raise ValueError(msg) - - children = [ - reactpy.html.button( - {"on_click": good_trigger, "id": "good", "key": "good"}, "good" - ), - reactpy.html.button( - {"on_click": bad_trigger, "id": "bad", "key": "bad"}, "bad" - ), - ] - - if reverse_children: - children.reverse() - - return reactpy.html.div(children) - - async with reactpy.Layout(MyComponent()) as layout: - await layout.render() - for _i in range(3): - event = event_message(good_handler.target) - await layout.deliver(event) - - assert called_good_trigger.current - # reset after checking - called_good_trigger.current = False - - await layout.render() - - assert not caplog.records - - -async def test_model_key_preserves_callback_identity_for_components(): - called_good_trigger = reactpy.Ref(False) - good_handler = StaticEventHandler() - bad_handler = StaticEventHandler() - - @reactpy.component - def RootComponent(): - reverse_children, set_reverse_children = use_toggle() - - children = [ - Trigger(set_reverse_children, name=name, key=name) - for name in ["good", "bad"] - ] - - if reverse_children: - children.reverse() - - return reactpy.html.div(children) - - @reactpy.component - def Trigger(set_reverse_children, name): - if name == "good": - - @good_handler.use - def callback(): - called_good_trigger.current = True - set_reverse_children() - - else: - - @bad_handler.use - def callback(): - msg = "Called bad trigger" - raise ValueError(msg) - - return reactpy.html.button({"on_click": callback, "id": "good"}, "good") - - async with reactpy.Layout(RootComponent()) as layout: - await layout.render() - for _ in range(3): - event = event_message(good_handler.target) - await layout.deliver(event) - - assert called_good_trigger.current - # reset after checking - called_good_trigger.current = False - - await layout.render() - - -async def test_component_can_return_another_component_directly(): - @reactpy.component - def Outer(): - return Inner() - - @reactpy.component - def Inner(): - return reactpy.html.div("hello") - - async with reactpy.Layout(Outer()) as layout: - assert (await layout.render()) == update_message( - path="", - model={ - "tagName": "", - "children": [ - { - "children": [{"children": ["hello"], "tagName": "div"}], - "tagName": "", - } - ], - }, - ) - - -async def test_hooks_for_keyed_components_get_garbage_collected(): - pop_item = reactpy.Ref(None) - garbage_collect_items = [] - registered_finalizers = set() - - @reactpy.component - def Outer(): - items, set_items = reactpy.hooks.use_state([1, 2, 3]) - pop_item.current = lambda: set_items(items[:-1]) - return reactpy.html.div(Inner(key=k, finalizer_id=k) for k in items) - - @reactpy.component - def Inner(finalizer_id): - if finalizer_id not in registered_finalizers: - hook = reactpy.hooks.current_hook() - finalize(hook, lambda: garbage_collect_items.append(finalizer_id)) - registered_finalizers.add(finalizer_id) - return reactpy.html.div(finalizer_id) - - async with reactpy.Layout(Outer()) as layout: - await layout.render() - - pop_item.current() - await layout.render() - assert garbage_collect_items == [3] - - pop_item.current() - await layout.render() - assert garbage_collect_items == [3, 2] - - pop_item.current() - await layout.render() - assert garbage_collect_items == [3, 2, 1] - - -async def test_event_handler_at_component_root_is_garbage_collected(): - event_handler = reactpy.Ref() - - @reactpy.component - def HasEventHandlerAtRoot(): - value, set_value = reactpy.hooks.use_state(False) - set_value(not value) # trigger renders forever - event_handler.current = weakref(set_value) - button = reactpy.html.button({"on_click": set_value}, "state is: ", value) - event_handler.current = weakref(button["eventHandlers"]["on_click"].function) - return button - - async with reactpy.Layout(HasEventHandlerAtRoot()) as layout: - await layout.render() - - for _i in range(3): - last_event_handler = event_handler.current - # after this render we should have release the reference to the last handler - await layout.render() - assert last_event_handler() is None - - -async def test_event_handler_deep_in_component_layout_is_garbage_collected(): - event_handler = reactpy.Ref() - - @reactpy.component - def HasNestedEventHandler(): - value, set_value = reactpy.hooks.use_state(False) - set_value(not value) # trigger renders forever - event_handler.current = weakref(set_value) - button = reactpy.html.button({"on_click": set_value}, "state is: ", value) - event_handler.current = weakref(button["eventHandlers"]["on_click"].function) - return reactpy.html.div(reactpy.html.div(button)) - - async with reactpy.Layout(HasNestedEventHandler()) as layout: - await layout.render() - - for _i in range(3): - last_event_handler = event_handler.current - # after this render we should have release the reference to the last handler - await layout.render() - assert last_event_handler() is None - - -async def test_duplicate_sibling_keys_causes_error(caplog): - hook = HookCatcher() - should_error = True - - @reactpy.component - @hook.capture - def ComponentReturnsDuplicateKeys(): - if should_error: - return reactpy.html.div( - reactpy.html.div({"key": "duplicate"}), - reactpy.html.div({"key": "duplicate"}), - ) - else: - return reactpy.html.div() - - async with reactpy.Layout(ComponentReturnsDuplicateKeys()) as layout: - with assert_reactpy_did_log( - error_type=ValueError, - match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - ): - await layout.render() - - hook.latest.schedule_render() - - should_error = False - await layout.render() - - should_error = True - hook.latest.schedule_render() - with assert_reactpy_did_log( - error_type=ValueError, - match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - ): - await layout.render() - - -async def test_keyed_components_preserve_hook_on_parent_update(): - outer_hook = HookCatcher() - inner_hook = HookCatcher() - - @reactpy.component - @outer_hook.capture - def Outer(): - return Inner(key=1) - - @reactpy.component - @inner_hook.capture - def Inner(): - return reactpy.html.div() - - async with reactpy.Layout(Outer()) as layout: - await layout.render() - old_inner_hook = inner_hook.latest - - outer_hook.latest.schedule_render() - await layout.render() - assert old_inner_hook is inner_hook.latest - - -async def test_log_error_on_bad_event_handler(): - bad_handler = StaticEventHandler() - - @reactpy.component - def ComponentWithBadEventHandler(): - @bad_handler.use - def raise_error(): - msg = "bad event handler" - raise Exception(msg) - - return reactpy.html.button({"on_click": raise_error}) - - with assert_reactpy_did_log(match_error="bad event handler"): - async with reactpy.Layout(ComponentWithBadEventHandler()) as layout: - await layout.render() - event = event_message(bad_handler.target) - await layout.deliver(event) - - -async def test_schedule_render_from_unmounted_hook(): - parent_set_state = reactpy.Ref() - - @reactpy.component - def Parent(): - state, parent_set_state.current = reactpy.hooks.use_state(1) - return Child(key=state, state=state) - - child_hook = HookCatcher() - - @reactpy.component - @child_hook.capture - def Child(state): - return reactpy.html.div(state) - - with assert_reactpy_did_log( - r"Did not render component with model state ID .*? - component already unmounted", - ): - async with reactpy.Layout(Parent()) as layout: - await layout.render() - - old_hook = child_hook.latest - - # cause initial child to be unmounted - parent_set_state.current(2) - await layout.render() - - # trigger render for hook that's been unmounted - old_hook.schedule_render() - - # schedule one more render just to make it so `layout.render()` doesn't hang - # when the scheduled render above gets skipped - parent_set_state.current(3) - - await layout.render() - - -async def test_elements_and_components_with_the_same_key_can_be_interchanged(): - set_toggle = reactpy.Ref() - effects = [] - - @reactpy.component - def Root(): - toggle, set_toggle.current = use_toggle(True) - if toggle: - return SomeComponent("x") - else: - return reactpy.html.div(SomeComponent("y")) - - @reactpy.component - def SomeComponent(name): - @use_effect - def some_effect(): - effects.append("mount " + name) - return lambda: effects.append("unmount " + name) - - return reactpy.html.div(name) - - async with reactpy.Layout(Root()) as layout: - await layout.render() - - assert effects == ["mount x"] - - set_toggle.current() - await layout.render() - - assert effects == ["mount x", "unmount x", "mount y"] - - set_toggle.current() - await layout.render() - - assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"] - - -async def test_layout_does_not_copy_element_children_by_key(): - # this is a regression test for a subtle bug: - # https://github.com/reactive-python/reactpy/issues/556 - - set_items = reactpy.Ref() - - @reactpy.component - def SomeComponent(): - items, set_items.current = reactpy.use_state([1, 2, 3]) - return reactpy.html.div( - [ - reactpy.html.div( - {"key": i}, - reactpy.html.input({"on_change": lambda event: None}), - ) - for i in items - ] - ) - - async with reactpy.Layout(SomeComponent()) as layout: - await layout.render() - - set_items.current([2, 3]) - - await layout.render() - - set_items.current([3]) - - await layout.render() - - set_items.current([]) - - await layout.render() - - -async def test_changing_key_of_parent_element_unmounts_children(): - random.seed(0) - - root_hook = HookCatcher() - state = reactpy.Ref(None) - - @reactpy.component - @root_hook.capture - def Root(): - return reactpy.html.div({"key": str(random.random())}, HasState()) - - @reactpy.component - def HasState(): - state.current = reactpy.hooks.use_state(random.random)[0] - return reactpy.html.div() - - async with reactpy.Layout(Root()) as layout: - await layout.render() - - for _i in range(5): - last_state = state.current - root_hook.latest.schedule_render() - await layout.render() - assert last_state != state.current - - -async def test_switching_node_type_with_event_handlers(): - toggle_type = reactpy.Ref() - element_static_handler = StaticEventHandler() - component_static_handler = StaticEventHandler() - - @reactpy.component - def Root(): - toggle, toggle_type.current = use_toggle(True) - handler = element_static_handler.use(lambda: None) - if toggle: - return html.div(html.button({"on_event": handler})) - else: - return html.div(SomeComponent()) - - @reactpy.component - def SomeComponent(): - handler = component_static_handler.use(lambda: None) - return html.button({"on_another_event": handler}) - - async with reactpy.Layout(Root()) as layout: - await layout.render() - - assert element_static_handler.target in layout._event_handlers - assert component_static_handler.target not in layout._event_handlers - - toggle_type.current() - await layout.render() - - assert element_static_handler.target not in layout._event_handlers - assert component_static_handler.target in layout._event_handlers - - toggle_type.current() - await layout.render() - - assert element_static_handler.target in layout._event_handlers - assert component_static_handler.target not in layout._event_handlers - - -async def test_switching_component_definition(): - toggle_component = reactpy.Ref() - first_used_state = reactpy.Ref(None) - second_used_state = reactpy.Ref(None) - - @reactpy.component - def Root(): - toggle, toggle_component.current = use_toggle(True) - if toggle: - return FirstComponent() - else: - return SecondComponent() - - @reactpy.component - def FirstComponent(): - first_used_state.current = use_state("first")[0] - # reset state after unmount - use_effect(lambda: lambda: first_used_state.set_current(None)) - return html.div() - - @reactpy.component - def SecondComponent(): - second_used_state.current = use_state("second")[0] - # reset state after unmount - use_effect(lambda: lambda: second_used_state.set_current(None)) - return html.div() - - async with reactpy.Layout(Root()) as layout: - await layout.render() - - assert first_used_state.current == "first" - assert second_used_state.current is None - - toggle_component.current() - await layout.render() - - assert first_used_state.current is None - assert second_used_state.current == "second" - - toggle_component.current() - await layout.render() - - assert first_used_state.current == "first" - assert second_used_state.current is None - - -async def test_element_keys_inside_components_do_not_reset_state_of_component(): - """This is a regression test for a bug. - - You would not expect that calling `set_child_key_num` would trigger state to be - reset in any `Child()` components but there was a bug where that happened. - """ - - effect_calls_without_state = set() - set_child_key_num = StaticEventHandler() - did_call_effect = asyncio.Event() - - @component - def Parent(): - state, set_state = use_state(0) - return html.div( - html.button( - {"on_click": set_child_key_num.use(lambda: set_state(state + 1))}, - "click me", - ), - Child("some-key"), - Child(f"key-{state}"), - ) - - @component - def Child(child_key): - state, set_state = use_state(0) - - @use_effect - async def record_if_state_is_reset(): - if state: - return - effect_calls_without_state.add(child_key) - set_state(1) - did_call_effect.set() - - return html.div({"key": child_key}, child_key) - - async with reactpy.Layout(Parent()) as layout: - await layout.render() - await did_call_effect.wait() - assert effect_calls_without_state == {"some-key", "key-0"} - did_call_effect.clear() - - for _i in range(1, 5): - await layout.deliver(event_message(set_child_key_num.target)) - await layout.render() - assert effect_calls_without_state == {"some-key", "key-0"} - did_call_effect.clear() - - -async def test_changing_key_of_component_resets_state(): - set_key = Ref() - did_init_state = Ref(0) - hook = HookCatcher() - - @component - @hook.capture - def Root(): - key, set_key.current = use_state("key-1") - return Child(key=key) - - @component - def Child(): - use_state(lambda: did_init_state.set_current(did_init_state.current + 1)) - - async with Layout(Root()) as layout: - await layout.render() - assert did_init_state.current == 1 - - set_key.current("key-2") - await layout.render() - assert did_init_state.current == 2 - - hook.latest.schedule_render() - await layout.render() - assert did_init_state.current == 2 - - -async def test_changing_event_handlers_in_the_next_render(): - set_event_name = Ref() - event_handler = StaticEventHandler() - did_trigger = Ref(False) - - @component - def Root(): - event_name, set_event_name.current = use_state("first") - return html.button( - {event_name: event_handler.use(lambda: did_trigger.set_current(True))} - ) - - async with Layout(Root()) as layout: - await layout.render() - await layout.deliver(event_message(event_handler.target)) - assert did_trigger.current - did_trigger.current = False - - set_event_name.current("second") - await layout.render() - await layout.deliver(event_message(event_handler.target)) - assert did_trigger.current - did_trigger.current = False - - -async def test_change_element_to_string_causes_unmount(): - set_toggle = Ref() - did_unmount = Ref(False) - - @component - def Root(): - toggle, set_toggle.current = use_toggle(True) - if toggle: - return html.div(Child()) - else: - return html.div("some-string") - - @component - def Child(): - use_effect(lambda: lambda: did_unmount.set_current(True)) - - async with Layout(Root()) as layout: - await layout.render() - - set_toggle.current() - - await layout.render() - - assert did_unmount.current - - -async def test_does_render_children_after_component(): - """Regression test for bug where layout was appending children to a stale ref - - The stale reference was created when a component got rendered. Thus, everything - after the component failed to display. - """ - - @reactpy.component - def Parent(): - return html.div( - html.p("first"), - Child(), - html.p("third"), - ) - - @reactpy.component - def Child(): - return html.p("second") - - async with reactpy.Layout(Parent()) as layout: - update = await layout.render() - assert update["model"] == { - "tagName": "", - "children": [ - { - "tagName": "div", - "children": [ - {"tagName": "p", "children": ["first"]}, - { - "tagName": "", - "children": [{"tagName": "p", "children": ["second"]}], - }, - {"tagName": "p", "children": ["third"]}, - ], - } - ], - } - - -async def test_render_removed_context_consumer(): - Context = reactpy.create_context(None) - toggle_remove_child = None - schedule_removed_child_render = None - - @component - def Parent(): - nonlocal toggle_remove_child - remove_child, toggle_remove_child = use_toggle() - return Context(html.div() if remove_child else Child(), value=None) - - @component - def Child(): - nonlocal schedule_removed_child_render - schedule_removed_child_render = use_force_render() - - async with reactpy.Layout(Parent()) as layout: - await layout.render() - - # If the context provider does not render its children then internally tracked - # state for the removed child component might not be cleaned up properly. This - # occurred in the past when the context provider implemented a should_render() - # method that returned False (and thus did not render its children) when the - # context value did not change. - toggle_remove_child() - await layout.render() - - # If this removed child component has state which has not been cleaned up - # correctly, scheduling a render for it might cause an error. - schedule_removed_child_render() - - # If things were cleaned up properly, the above scheduled render should not - # actually take place. Thus we expect the timeout to occur. - render_task = asyncio.create_task(layout.render()) - done, pending = await asyncio.wait([render_task], timeout=0.1) - assert not done and pending - render_task.cancel() diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py deleted file mode 100644 index 64be0ec8b..000000000 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ /dev/null @@ -1,139 +0,0 @@ -import asyncio -from collections.abc import Sequence -from typing import Any - -from jsonpointer import set_pointer - -import reactpy -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.core.types import LayoutUpdateMessage -from reactpy.testing import StaticEventHandler -from tests.tooling.common import event_message - -EVENT_NAME = "on_event" -STATIC_EVENT_HANDLER = StaticEventHandler() - - -def make_send_recv_callbacks(events_to_inject): - changes = [] - - # We need a semaphore here to simulate receiving an event after each update is sent. - # The effect is that the send() and recv() callbacks trade off control. If we did - # not do this, it would easy to determine when to halt because, while we might have - # received all the events, they might not have been sent since the two callbacks are - # executed in separate loops. - sem = asyncio.Semaphore(0) - - async def send(patch): - changes.append(patch) - sem.release() - if not events_to_inject: - raise reactpy.Stop() - - async def recv(): - await sem.acquire() - try: - return events_to_inject.pop(0) - except IndexError: - # wait forever - await asyncio.Event().wait() - - return changes, send, recv - - -def make_events_and_expected_model(): - events = [event_message(STATIC_EVENT_HANDLER.target)] * 4 - expected_model = { - "tagName": "", - "children": [ - { - "tagName": "div", - "attributes": {"count": 4}, - "eventHandlers": { - EVENT_NAME: { - "target": STATIC_EVENT_HANDLER.target, - "preventDefault": False, - "stopPropagation": False, - } - }, - } - ], - } - return events, expected_model - - -def assert_changes_produce_expected_model( - changes: Sequence[LayoutUpdateMessage], - expected_model: Any, -) -> None: - model_from_changes = {} - for update in changes: - if update["path"]: - model_from_changes = set_pointer( - model_from_changes, update["path"], update["model"] - ) - else: - model_from_changes.update(update["model"]) - assert model_from_changes == expected_model - - -@reactpy.component -def Counter(): - count, change_count = reactpy.hooks.use_reducer( - (lambda old_count, diff: old_count + diff), - initial_value=0, - ) - handler = STATIC_EVENT_HANDLER.use(lambda: change_count(1)) - return reactpy.html.div({EVENT_NAME: handler, "count": count}) - - -async def test_dispatch(): - events, expected_model = make_events_and_expected_model() - changes, send, recv = make_send_recv_callbacks(events) - await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1) - assert_changes_produce_expected_model(changes, expected_model) - - -async def test_dispatcher_handles_more_than_one_event_at_a_time(): - block_and_never_set = asyncio.Event() - will_block = asyncio.Event() - second_event_did_execute = asyncio.Event() - - blocked_handler = StaticEventHandler() - non_blocked_handler = StaticEventHandler() - - @reactpy.component - def ComponentWithTwoEventHandlers(): - @blocked_handler.use - async def block_forever(): - will_block.set() - await block_and_never_set.wait() - - @non_blocked_handler.use - async def handle_event(): - second_event_did_execute.set() - - return reactpy.html.div( - reactpy.html.button({"on_click": block_forever}), - reactpy.html.button({"on_click": handle_event}), - ) - - send_queue = asyncio.Queue() - recv_queue = asyncio.Queue() - - task = asyncio.create_task( - serve_layout( - reactpy.Layout(ComponentWithTwoEventHandlers()), - send_queue.put, - recv_queue.get, - ) - ) - - await recv_queue.put(event_message(blocked_handler.target)) - await will_block.wait() - - await recv_queue.put(event_message(non_blocked_handler.target)) - await second_event_did_execute.wait() - - task.cancel() diff --git a/src/py/reactpy/tests/test_core/test_vdom.py b/src/py/reactpy/tests/test_core/test_vdom.py deleted file mode 100644 index 76e26e46f..000000000 --- a/src/py/reactpy/tests/test_core/test_vdom.py +++ /dev/null @@ -1,307 +0,0 @@ -import sys - -import pytest -from fastjsonschema import JsonSchemaException - -import reactpy -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.events import EventHandler -from reactpy.core.types import VdomDict -from reactpy.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json - -FAKE_EVENT_HANDLER = EventHandler(lambda data: None) -FAKE_EVENT_HANDLER_DICT = {"on_event": FAKE_EVENT_HANDLER} - - -@pytest.mark.parametrize( - "result, value", - [ - (False, {}), - (False, {"tagName": None}), - (False, VdomDict()), - (True, {"tagName": ""}), - (True, VdomDict(tagName="")), - ], -) -def test_is_vdom(result, value): - assert is_vdom(value) == result - - -@pytest.mark.parametrize( - "actual, expected", - [ - ( - reactpy.vdom("div", [reactpy.vdom("div")]), - {"tagName": "div", "children": [{"tagName": "div"}]}, - ), - ( - reactpy.vdom("div", {"style": {"backgroundColor": "red"}}), - {"tagName": "div", "attributes": {"style": {"backgroundColor": "red"}}}, - ), - ( - # multiple iterables of children are merged - reactpy.vdom("div", [reactpy.vdom("div"), 1], (reactpy.vdom("div"), 2)), - { - "tagName": "div", - "children": [{"tagName": "div"}, 1, {"tagName": "div"}, 2], - }, - ), - ( - reactpy.vdom("div", {"on_event": FAKE_EVENT_HANDLER}), - {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, - ), - ( - reactpy.vdom("div", reactpy.html.h1("hello"), reactpy.html.h2("world")), - { - "tagName": "div", - "children": [ - {"tagName": "h1", "children": ["hello"]}, - {"tagName": "h2", "children": ["world"]}, - ], - }, - ), - ( - reactpy.vdom("div", {"tagName": "div"}), - {"tagName": "div", "children": [{"tagName": "div"}]}, - ), - ( - reactpy.vdom("div", (i for i in range(3))), - {"tagName": "div", "children": [0, 1, 2]}, - ), - ( - reactpy.vdom("div", (x**2 for x in [1, 2, 3])), - {"tagName": "div", "children": [1, 4, 9]}, - ), - ], -) -def test_simple_node_construction(actual, expected): - assert actual == expected - - -async def test_callable_attributes_are_cast_to_event_handlers(): - params_from_calls = [] - - node = reactpy.vdom( - "div", {"on_event": lambda *args: params_from_calls.append(args)} - ) - - event_handlers = node.pop("eventHandlers") - assert node == {"tagName": "div"} - - handler = event_handlers["on_event"] - assert event_handlers == {"on_event": EventHandler(handler.function)} - - await handler.function([1, 2]) - await handler.function([3, 4, 5]) - assert params_from_calls == [(1, 2), (3, 4, 5)] - - -def test_make_vdom_constructor(): - elmt = make_vdom_constructor("some-tag") - - assert elmt({"data": 1}, [elmt()]) == { - "tagName": "some-tag", - "children": [{"tagName": "some-tag"}], - "attributes": {"data": 1}, - } - - no_children = make_vdom_constructor("no-children", allow_children=False) - - with pytest.raises(TypeError, match="cannot have children"): - no_children([1, 2, 3]) - - assert no_children() == {"tagName": "no-children"} - - -@pytest.mark.parametrize( - "value", - [ - { - "tagName": "div", - "children": [ - "Some text", - {"tagName": "div"}, - ], - }, - { - "tagName": "div", - "attributes": {"style": {"color": "blue"}}, - }, - { - "tagName": "div", - "eventHandler": {"target": "something"}, - }, - { - "tagName": "div", - "eventHandler": { - "target": "something", - "preventDefault": False, - "stopPropagation": True, - }, - }, - { - "tagName": "div", - "importSource": {"source": "something"}, - }, - { - "tagName": "div", - "importSource": {"source": "something", "fallback": None}, - }, - { - "tagName": "div", - "importSource": {"source": "something", "fallback": "loading..."}, - }, - { - "tagName": "div", - "importSource": {"source": "something", "fallback": {"tagName": "div"}}, - }, - { - "tagName": "div", - "children": [ - "Some text", - {"tagName": "div"}, - ], - "attributes": {"style": {"color": "blue"}}, - "eventHandler": { - "target": "something", - "preventDefault": False, - "stopPropagation": True, - }, - "importSource": { - "source": "something", - "fallback": {"tagName": "div"}, - }, - }, - ], -) -def test_valid_vdom(value): - validate_vdom_json(value) - - -@pytest.mark.skipif( - sys.version_info < (3, 10), reason="error messages are different in Python<3.10" -) -@pytest.mark.parametrize( - "value, error_message_pattern", - [ - ( - None, - r"data must be object", - ), - ( - {}, - r"data must contain \['tagName'\] properties", - ), - ( - {"tagName": 0}, - r"data\.tagName must be string", - ), - ( - {"tagName": "tag", "children": None}, - r"data\.children must be array", - ), - ( - {"tagName": "tag", "children": [None]}, - r"data\.children\[0\] must be object or string", - ), - ( - {"tagName": "tag", "children": [{"tagName": None}]}, - r"data\.children\[0\]\.tagName must be string", - ), - ( - {"tagName": "tag", "attributes": None}, - r"data\.attributes must be object", - ), - ( - {"tagName": "tag", "eventHandlers": None}, - r"data\.eventHandlers must be object", - ), - ( - {"tagName": "tag", "eventHandlers": {"on_event": None}}, - r"data\.eventHandlers\.on_event must be object", - ), - ( - { - "tagName": "tag", - "eventHandlers": {"on_event": {}}, - }, - r"data\.eventHandlers\.on_event\ must contain \['target'\] properties", - ), - ( - { - "tagName": "tag", - "eventHandlers": { - "on_event": { - "target": "something", - "preventDefault": None, - } - }, - }, - r"data\.eventHandlers\.on_event\.preventDefault must be boolean", - ), - ( - { - "tagName": "tag", - "eventHandlers": { - "on_event": { - "target": "something", - "stopPropagation": None, - } - }, - }, - r"data\.eventHandlers\.on_event\.stopPropagation must be boolean", - ), - ( - {"tagName": "tag", "importSource": None}, - r"data\.importSource must be object", - ), - ( - {"tagName": "tag", "importSource": {}}, - r"data\.importSource must contain \['source'\] properties", - ), - ( - { - "tagName": "tag", - "importSource": {"source": "something", "fallback": 0}, - }, - r"data\.importSource\.fallback must be object or string or null", - ), - ( - { - "tagName": "tag", - "importSource": {"source": "something", "fallback": {"tagName": None}}, - }, - r"data\.importSource\.fallback\.tagName must be string", - ), - ], -) -def test_invalid_vdom(value, error_message_pattern): - with pytest.raises(JsonSchemaException, match=error_message_pattern): - validate_vdom_json(value) - - -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="Only logs in debug mode") -def test_debug_log_cannot_verify_keypath_for_genereators(caplog): - reactpy.vdom("div", (1 for i in range(10))) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith( - "Did not verify key-path integrity of children in generator" - ) - caplog.records.clear() - - -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="Only logs in debug mode") -def test_debug_log_dynamic_children_must_have_keys(caplog): - reactpy.vdom("div", [reactpy.vdom("div")]) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith("Key not specified for child") - - caplog.records.clear() - - @reactpy.component - def MyComponent(): - return reactpy.vdom("div") - - reactpy.vdom("div", [MyComponent()]) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith("Key not specified for child") diff --git a/src/py/reactpy/tests/test_html.py b/src/py/reactpy/tests/test_html.py deleted file mode 100644 index f16d1beed..000000000 --- a/src/py/reactpy/tests/test_html.py +++ /dev/null @@ -1,160 +0,0 @@ -import pytest - -from reactpy import component, config, html -from reactpy.testing import DisplayFixture, poll -from reactpy.utils import Ref -from tests.tooling.hooks import use_counter, use_toggle - - -async def test_script_mount_unmount(display: DisplayFixture): - toggle_is_mounted = Ref() - - @component - def Root(): - is_mounted, toggle_is_mounted.current = use_toggle(True) - return html.div( - html.div({"id": "mount-state", "data_value": False}), - HasScript() if is_mounted else html.div(), - ) - - @component - def HasScript(): - return html.script( - """() => { - const mapping = {"false": false, "true": true}; - const mountStateEl = document.getElementById("mount-state"); - mountStateEl.setAttribute( - "data-value", !mapping[mountStateEl.getAttribute("data-value")]); - return () => mountStateEl.setAttribute( - "data-value", !mapping[mountStateEl.getAttribute("data-value")]); - }""" - ) - - await display.show(Root) - - mount_state = await display.page.wait_for_selector("#mount-state", state="attached") - poll_mount_state = poll(mount_state.get_attribute, "data-value") - - await poll_mount_state.until_equals("true") - - toggle_is_mounted.current() - - await poll_mount_state.until_equals("false") - - toggle_is_mounted.current() - - await poll_mount_state.until_equals("true") - - -async def test_script_re_run_on_content_change(display: DisplayFixture): - incr_count = Ref() - - @component - def HasScript(): - count, incr_count.current = use_counter(1) - return html.div( - html.div({"id": "mount-count", "data_value": 0}), - html.div({"id": "unmount-count", "data_value": 0}), - html.script( - f"""() => {{ - const mountCountEl = document.getElementById("mount-count"); - const unmountCountEl = document.getElementById("unmount-count"); - mountCountEl.setAttribute("data-value", {count}); - return () => unmountCountEl.setAttribute("data-value", {count});; - }}""" - ), - ) - - await display.show(HasScript) - - mount_count = await display.page.wait_for_selector("#mount-count", state="attached") - poll_mount_count = poll(mount_count.get_attribute, "data-value") - - unmount_count = await display.page.wait_for_selector( - "#unmount-count", state="attached" - ) - poll_unmount_count = poll(unmount_count.get_attribute, "data-value") - - await poll_mount_count.until_equals("1") - await poll_unmount_count.until_equals("0") - - incr_count.current() - - await poll_mount_count.until_equals("2") - await poll_unmount_count.until_equals("1") - - incr_count.current() - - await poll_mount_count.until_equals("3") - await poll_unmount_count.until_equals("2") - - -async def test_script_from_src(display: DisplayFixture): - incr_src_id = Ref() - file_name_template = "__some_js_script_{src_id}__.js" - - @component - def HasScript(): - src_id, incr_src_id.current = use_counter(0) - if src_id == 0: - # on initial display we haven't added the file yet. - return html.div() - else: - return html.div( - html.div({"id": "run-count", "data_value": 0}), - html.script( - { - "src": f"/_reactpy/modules/{file_name_template.format(src_id=src_id)}" - } - ), - ) - - await display.show(HasScript) - - for i in range(1, 4): - script_file = ( - config.REACTPY_WEB_MODULES_DIR.current / file_name_template.format(src_id=i) - ) - script_file.write_text( - f""" - let runCountEl = document.getElementById("run-count"); - runCountEl.setAttribute("data-value", {i}); - """ - ) - - incr_src_id.current() - - run_count = await display.page.wait_for_selector("#run-count", state="attached") - poll_run_count = poll(run_count.get_attribute, "data-value") - await poll_run_count.until_equals("1") - - -def test_script_may_only_have_one_child(): - with pytest.raises(ValueError, match="'script' nodes may have, at most, one child"): - html.script("one child", "two child") - - -def test_child_of_script_must_be_string(): - with pytest.raises(ValueError, match="The child of a 'script' must be a string"): - html.script(1) - - -def test_script_has_no_event_handlers(): - with pytest.raises(ValueError, match="do not support event handlers"): - html.script({"on_event": lambda: None}) - - -def test_simple_fragment(): - assert html._() == {"tagName": ""} - assert html._(1, 2, 3) == {"tagName": "", "children": [1, 2, 3]} - assert html._({"key": "something"}) == {"tagName": "", "key": "something"} - assert html._({"key": "something"}, 1, 2, 3) == { - "tagName": "", - "key": "something", - "children": [1, 2, 3], - } - - -def test_fragment_can_have_no_attributes(): - with pytest.raises(TypeError, match="Fragments cannot have attributes"): - html._({"some_attribute": 1}) diff --git a/src/py/reactpy/tests/test_sample.py b/src/py/reactpy/tests/test_sample.py deleted file mode 100644 index b92e89789..000000000 --- a/src/py/reactpy/tests/test_sample.py +++ /dev/null @@ -1,8 +0,0 @@ -from reactpy.sample import SampleApp -from reactpy.testing import DisplayFixture - - -async def test_sample_app(display: DisplayFixture): - await display.show(SampleApp) - h1 = await display.page.wait_for_selector("h1") - assert (await h1.text_content()) == "Sample Application" diff --git a/src/py/reactpy/tests/test_testing.py b/src/py/reactpy/tests/test_testing.py deleted file mode 100644 index 68e36e7f6..000000000 --- a/src/py/reactpy/tests/test_testing.py +++ /dev/null @@ -1,218 +0,0 @@ -import logging -import os - -import pytest - -from reactpy import Ref, component, html, testing -from reactpy.backend import starlette as starlette_implementation -from reactpy.logging import ROOT_LOGGER -from reactpy.sample import SampleApp -from reactpy.testing.backend import _hotswap -from reactpy.testing.display import DisplayFixture - - -def test_assert_reactpy_logged_does_not_suppress_errors(): - with pytest.raises(RuntimeError, match="expected error"): - with testing.assert_reactpy_did_log(): - msg = "expected error" - raise RuntimeError(msg) - - -def test_assert_reactpy_logged_message(): - with testing.assert_reactpy_did_log(match_message="my message"): - ROOT_LOGGER.info("my message") - - with testing.assert_reactpy_did_log(match_message=r".*"): - ROOT_LOGGER.info("my message") - - -def test_assert_reactpy_logged_error(): - with testing.assert_reactpy_did_log( - match_message="log message", - error_type=ValueError, - match_error="my value error", - ): - try: - msg = "my value error" - raise ValueError(msg) - except ValueError: - ROOT_LOGGER.exception("log message") - - with pytest.raises( - AssertionError, - match=r"Could not find a log record matching the given", - ): - with testing.assert_reactpy_did_log( - match_message="log message", - error_type=ValueError, - match_error="my value error", - ): - try: - # change error type - msg = "my value error" - raise RuntimeError(msg) - except RuntimeError: - ROOT_LOGGER.exception("log message") - - with pytest.raises( - AssertionError, - match=r"Could not find a log record matching the given", - ): - with testing.assert_reactpy_did_log( - match_message="log message", - error_type=ValueError, - match_error="my value error", - ): - try: - # change error message - msg = "something else" - raise ValueError(msg) - except ValueError: - ROOT_LOGGER.exception("log message") - - with pytest.raises( - AssertionError, - match=r"Could not find a log record matching the given", - ): - with testing.assert_reactpy_did_log( - match_message="log message", - error_type=ValueError, - match_error="my value error", - ): - try: - # change error message - msg = "my error message" - raise ValueError(msg) - except ValueError: - ROOT_LOGGER.exception("something else") - - -def test_assert_reactpy_logged_assertion_error_message(): - with pytest.raises( - AssertionError, - match=r"Could not find a log record matching the given", - ): - with testing.assert_reactpy_did_log( - # put in all possible params full assertion error message - match_message=r".*", - error_type=Exception, - match_error=r".*", - ): - pass - - -def test_assert_reactpy_logged_ignores_level(): - original_level = ROOT_LOGGER.level - ROOT_LOGGER.setLevel(logging.INFO) - try: - with testing.assert_reactpy_did_log(match_message=r".*"): - # this log would normally be ignored - ROOT_LOGGER.debug("my message") - finally: - ROOT_LOGGER.setLevel(original_level) - - -def test_assert_reactpy_did_not_log(): - with testing.assert_reactpy_did_not_log(match_message="my message"): - pass - - with testing.assert_reactpy_did_not_log(match_message=r"something else"): - ROOT_LOGGER.info("my message") - - with pytest.raises( - AssertionError, - match=r"Did find a log record matching the given", - ): - with testing.assert_reactpy_did_not_log( - # put in all possible params full assertion error message - match_message=r".*", - error_type=Exception, - match_error=r".*", - ): - try: - msg = "something" - raise Exception(msg) - except Exception: - ROOT_LOGGER.exception("something") - - -async def test_simple_display_fixture(): - if os.name == "nt": - pytest.skip("Browser tests not supported on Windows") - async with testing.DisplayFixture() as display: - await display.show(SampleApp) - await display.page.wait_for_selector("#sample") - - -def test_if_app_is_given_implementation_must_be_too(): - with pytest.raises( - ValueError, - match=r"If an application instance its corresponding server implementation must be provided too", - ): - testing.BackendFixture(app=starlette_implementation.create_development_app()) - - testing.BackendFixture( - app=starlette_implementation.create_development_app(), - implementation=starlette_implementation, - ) - - -def test_list_logged_excptions(): - the_error = None - with testing.capture_reactpy_logs() as records: - ROOT_LOGGER.info("A non-error log message") - - try: - msg = "An error for testing" - raise ValueError(msg) - except Exception as error: - ROOT_LOGGER.exception("Log the error") - the_error = error - - logged_errors = testing.logs.list_logged_exceptions(records) - assert logged_errors == [the_error] - - -async def test_hostwap_update_on_change(display: DisplayFixture): - """Ensure shared hotswapping works - - This basically means that previously rendered views of a hotswap component get updated - when a new view is mounted, not just the next time it is re-displayed - - In this test we construct a scenario where clicking a button will cause a pre-existing - hotswap component to be updated - """ - - def make_next_count_constructor(count): - """We need to construct a new function so they're different when we set_state""" - - def constructor(): - count.current += 1 - return html.div({"id": f"hotswap-{count.current}"}, count.current) - - return constructor - - @component - def ButtonSwapsDivs(): - count = Ref(0) - - async def on_click(event): - mount(make_next_count_constructor(count)) - - incr = html.button({"on_click": on_click, "id": "incr-button"}, "incr") - - mount, make_hostswap = _hotswap(update_on_change=True) - mount(make_next_count_constructor(count)) - hotswap_view = make_hostswap() - - return html.div(incr, hotswap_view) - - await display.show(ButtonSwapsDivs) - - client_incr_button = await display.page.wait_for_selector("#incr-button") - - await display.page.wait_for_selector("#hotswap-1") - await client_incr_button.click() - await display.page.wait_for_selector("#hotswap-2") - await client_incr_button.click() - await display.page.wait_for_selector("#hotswap-3") diff --git a/src/py/reactpy/tests/test_utils.py b/src/py/reactpy/tests/test_utils.py deleted file mode 100644 index c71057f15..000000000 --- a/src/py/reactpy/tests/test_utils.py +++ /dev/null @@ -1,274 +0,0 @@ -from html import escape as html_escape - -import pytest - -import reactpy -from reactpy import html -from reactpy.utils import ( - HTMLParseError, - del_html_head_body_transform, - html_to_vdom, - vdom_to_html, -) - - -def test_basic_ref_behavior(): - r = reactpy.Ref(1) - assert r.current == 1 - - r.current = 2 - assert r.current == 2 - - assert r.set_current(3) == 2 - assert r.current == 3 - - r = reactpy.Ref() - with pytest.raises(AttributeError): - r.current # noqa: B018 - - r.current = 4 - assert r.current == 4 - - -def test_ref_equivalence(): - assert reactpy.Ref([1, 2, 3]) == reactpy.Ref([1, 2, 3]) - assert reactpy.Ref([1, 2, 3]) != reactpy.Ref([1, 2]) - assert reactpy.Ref([1, 2, 3]) != [1, 2, 3] - assert reactpy.Ref() != reactpy.Ref() - assert reactpy.Ref() != reactpy.Ref(1) - - -def test_ref_repr(): - assert repr(reactpy.Ref([1, 2, 3])) == "Ref([1, 2, 3])" - assert repr(reactpy.Ref()) == "Ref(<undefined>)" - - -@pytest.mark.parametrize( - "case", - [ - {"source": "<div/>", "model": {"tagName": "div"}}, - { - "source": "<div style='background-color:blue'/>", - "model": { - "tagName": "div", - "attributes": {"style": {"background_color": "blue"}}, - }, - }, - { - "source": "<div>Hello!</div>", - "model": {"tagName": "div", "children": ["Hello!"]}, - }, - { - "source": "<div>Hello!<p>World!</p></div>", - "model": { - "tagName": "div", - "children": ["Hello!", {"tagName": "p", "children": ["World!"]}], - }, - }, - ], -) -def test_html_to_vdom(case): - assert html_to_vdom(case["source"]) == case["model"] - - -def test_html_to_vdom_transform(): - source = "<p>hello <a>world</a> and <a>universe</a>lmao</p>" - - def make_links_blue(node): - if node["tagName"] == "a": - node["attributes"] = {"style": {"color": "blue"}} - return node - - expected = { - "tagName": "p", - "children": [ - "hello ", - { - "tagName": "a", - "children": ["world"], - "attributes": {"style": {"color": "blue"}}, - }, - " and ", - { - "tagName": "a", - "children": ["universe"], - "attributes": {"style": {"color": "blue"}}, - }, - "lmao", - ], - } - - assert html_to_vdom(source, make_links_blue) == expected - - -def test_non_html_tag_behavior(): - source = "<my-tag data-x=something><my-other-tag key=a-key /></my-tag>" - - expected = { - "tagName": "my-tag", - "attributes": {"data-x": "something"}, - "children": [ - {"tagName": "my-other-tag", "key": "a-key"}, - ], - } - - assert html_to_vdom(source, strict=False) == expected - - with pytest.raises(HTMLParseError): - html_to_vdom(source, strict=True) - - -def test_html_to_vdom_with_null_tag(): - source = "<p>hello<br>world</p>" - - expected = { - "tagName": "p", - "children": [ - "hello", - {"tagName": "br"}, - "world", - ], - } - - assert html_to_vdom(source) == expected - - -def test_html_to_vdom_with_style_attr(): - source = '<p style="color: red; background-color : green; ">Hello World.</p>' - - expected = { - "attributes": {"style": {"background_color": "green", "color": "red"}}, - "children": ["Hello World."], - "tagName": "p", - } - - assert html_to_vdom(source) == expected - - -def test_html_to_vdom_with_no_parent_node(): - source = "<p>Hello</p><div>World</div>" - - expected = { - "tagName": "div", - "children": [ - {"tagName": "p", "children": ["Hello"]}, - {"tagName": "div", "children": ["World"]}, - ], - } - - assert html_to_vdom(source) == expected - - -def test_del_html_body_transform(): - source = """ - <!DOCTYPE html> - <html lang="en"> - - <head> - <title>My Title</title> - </head> - - <body><h1>Hello World</h1></body> - - </html> - """ - - expected = { - "tagName": "", - "children": [ - { - "tagName": "", - "children": [{"tagName": "title", "children": ["My Title"]}], - }, - { - "tagName": "", - "children": [{"tagName": "h1", "children": ["Hello World"]}], - }, - ], - } - - assert html_to_vdom(source, del_html_head_body_transform) == expected - - -SOME_OBJECT = object() - - -@pytest.mark.parametrize( - "vdom_in, html_out", - [ - ( - html.div("hello"), - "<div>hello</div>", - ), - ( - html.div(SOME_OBJECT), - f"<div>{html_escape(str(SOME_OBJECT))}</div>", - ), - ( - html.div({"someAttribute": SOME_OBJECT}), - f'<div someattribute="{html_escape(str(SOME_OBJECT))}"></div>', - ), - ( - html.div( - "hello", html.a({"href": "https://example.com"}, "example"), "world" - ), - '<div>hello<a href="https://example.com">example</a>world</div>', - ), - ( - html.button({"on_click": lambda event: None}), - "<button></button>", - ), - ( - html._("hello ", html._("world")), - "hello world", - ), - ( - html._(html.div("hello"), html._("world")), - "<div>hello</div>world", - ), - ( - html.div({"style": {"backgroundColor": "blue", "marginLeft": "10px"}}), - '<div style="background-color:blue;margin-left:10px"></div>', - ), - ( - html.div({"style": "background-color:blue;margin-left:10px"}), - '<div style="background-color:blue;margin-left:10px"></div>', - ), - ( - html._( - html.div("hello"), - html.a({"href": "https://example.com"}, "example"), - ), - '<div>hello</div><a href="https://example.com">example</a>', - ), - ( - html.div( - html._( - html.div("hello"), - html.a({"href": "https://example.com"}, "example"), - ), - html.button(), - ), - '<div><div>hello</div><a href="https://example.com">example</a><button></button></div>', - ), - ( - html.div( - {"data_something": 1, "data_something_else": 2, "dataisnotdashed": 3} - ), - '<div data-something="1" data-something-else="2" dataisnotdashed="3"></div>', - ), - ( - html.div( - {"dataSomething": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} - ), - '<div data-something="1" data-something-else="2" dataisnotdashed="3"></div>', - ), - ], -) -def test_vdom_to_html(vdom_in, html_out): - assert vdom_to_html(vdom_in) == html_out - - -def test_vdom_to_html_error(): - with pytest.raises(TypeError, match="Expected a VDOM dict"): - vdom_to_html({"notVdom": True}) diff --git a/src/py/reactpy/tests/test_web/js_fixtures/component-can-have-child.js b/src/py/reactpy/tests/test_web/js_fixtures/component-can-have-child.js deleted file mode 100644 index fd443b164..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/component-can-have-child.js +++ /dev/null @@ -1,27 +0,0 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; - -const html = htm.bind(h); - -export function bind(node, config) { - return { - create: (type, props, children) => h(type, props, ...children), - render: (element) => render(element, node), - unmount: () => render(null, node), - }; -} - -// The intention here is that Child components are passed in here so we check that the -// children of "the-parent" are "child-1" through "child-N" -export function Parent(props) { - return html` - <div> - <p>the parent</p> - <ul id="the-parent">${props.children}</div> - </div> - `; -} - -export function Child({ index }) { - return html`<li id="child-${index}">child ${index}</li>`; -} diff --git a/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/index.js b/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/index.js deleted file mode 100644 index 372f108bd..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { index as Index }; -export * from "./one.js"; diff --git a/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/one.js b/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/one.js deleted file mode 100644 index ddea9d2cb..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/one.js +++ /dev/null @@ -1,5 +0,0 @@ -export { one as One }; -// use ../ just to check that it works -export * from "../export-resolution/two.js"; -// this default should not be exported by the * re-export in index.js -export default 0; diff --git a/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/two.js b/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/two.js deleted file mode 100644 index f01389d2e..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/export-resolution/two.js +++ /dev/null @@ -1,2 +0,0 @@ -export { two as Two }; -export * from "https://some.external.url"; diff --git a/src/py/reactpy/tests/test_web/js_fixtures/exports-syntax.js b/src/py/reactpy/tests/test_web/js_fixtures/exports-syntax.js deleted file mode 100644 index 8f9b0e612..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/exports-syntax.js +++ /dev/null @@ -1,23 +0,0 @@ -// Copied from: https://developer.mozilla.org/en-US/docs/web/javascript/reference/statements/export - -// Exporting individual features -export let name1, name2, name3; // also var, const -export let name4 = 4, name5 = 5, name6; // also var, const -export function functionName(){...} -export class ClassName {...} - -// Export list -export { name7, name8, name9 }; - -// Renaming exports -export { variable1 as name10, variable2 as name11, name12 }; - -// Exporting destructured assignments with renaming -export const { name13, name14: bar } = o; - -// Aggregating modules -export * from "https://source1.com"; // does not set the default export -export * from "https://source2.com"; // does not set the default export -export * as name15 from "https://source3.com"; // Draft ECMAScript® 2O21 -export { name16, name17 } from "https://source4.com"; -export { import1 as name18, import2 as name19, name20 } from "https://source5.com"; diff --git a/src/py/reactpy/tests/test_web/js_fixtures/exports-two-components.js b/src/py/reactpy/tests/test_web/js_fixtures/exports-two-components.js deleted file mode 100644 index 10aa7fdbe..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/exports-two-components.js +++ /dev/null @@ -1,20 +0,0 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; - -const html = htm.bind(h); - -export function bind(node, config) { - return { - create: (type, props, children) => h(type, props, ...children), - render: (element) => render(element, node), - unmount: () => render(null, node), - }; -} - -export function Header1(props) { - return h("h1", { id: props.id }, props.text); -} - -export function Header2(props) { - return h("h2", { id: props.id }, props.text); -} diff --git a/src/py/reactpy/tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js b/src/py/reactpy/tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js deleted file mode 100644 index dbb1a1c99..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js +++ /dev/null @@ -1,31 +0,0 @@ -export function bind(node, config) { - return { - create: (type, props, children) => type(props), - render: (element) => renderElement(element, node), - unmount: () => unmountElement(node), - }; -} - -export function renderElement(element, container) { - if (container.firstChild) { - container.removeChild(container.firstChild); - } - container.appendChild(element); -} - -export function unmountElement(container) { - // We add an element to the document.body to indicate that this function was called. - // Thus allowing Selenium to see communicate to server-side code that this effect - // did indeed occur. - const unmountFlag = document.createElement("h1"); - unmountFlag.setAttribute("id", "unmount-flag"); - document.body.appendChild(unmountFlag); - container.innerHTML = ""; -} - -export function SomeComponent(props) { - const element = document.createElement("h1"); - element.appendChild(document.createTextNode(props.text)); - element.setAttribute("id", props.id); - return element; -} diff --git a/src/py/reactpy/tests/test_web/js_fixtures/simple-button.js b/src/py/reactpy/tests/test_web/js_fixtures/simple-button.js deleted file mode 100644 index 2b49f505b..000000000 --- a/src/py/reactpy/tests/test_web/js_fixtures/simple-button.js +++ /dev/null @@ -1,25 +0,0 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; - -const html = htm.bind(h); - -export function bind(node, config) { - return { - create: (type, props, children) => h(type, props, ...children), - render: (element) => render(element, node), - unmount: () => render(null, node), - }; -} - -export function SimpleButton(props) { - return h( - "button", - { - id: props.id, - onClick(event) { - props.onClick({ data: props.eventResponseData }); - }, - }, - "simple button", - ); -} diff --git a/src/py/reactpy/tests/test_web/test_module.py b/src/py/reactpy/tests/test_web/test_module.py deleted file mode 100644 index f8783337d..000000000 --- a/src/py/reactpy/tests/test_web/test_module.py +++ /dev/null @@ -1,235 +0,0 @@ -from pathlib import Path - -import pytest -from sanic import Sanic - -import reactpy -from reactpy.backend import sanic as sanic_implementation -from reactpy.testing import ( - BackendFixture, - DisplayFixture, - assert_reactpy_did_log, - assert_reactpy_did_not_log, - poll, -) -from reactpy.web.module import NAME_SOURCE, WebModule - -JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" - - -async def test_that_js_module_unmount_is_called(display: DisplayFixture): - SomeComponent = reactpy.web.export( - reactpy.web.module_from_file( - "set-flag-when-unmount-is-called", - JS_FIXTURES_DIR / "set-flag-when-unmount-is-called.js", - ), - "SomeComponent", - ) - - set_current_component = reactpy.Ref(None) - - @reactpy.component - def ShowCurrentComponent(): - current_component, set_current_component.current = reactpy.hooks.use_state( - lambda: SomeComponent({"id": "some-component", "text": "initial component"}) - ) - return current_component - - await display.show(ShowCurrentComponent) - - await display.page.wait_for_selector("#some-component", state="attached") - - set_current_component.current( - reactpy.html.h1({"id": "some-other-component"}, "some other component") - ) - - # the new component has been displayed - await display.page.wait_for_selector("#some-other-component", state="attached") - - # the unmount callback for the old component was called - await display.page.wait_for_selector("#unmount-flag", state="attached") - - -async def test_module_from_url(browser): - app = Sanic("test_module_from_url") - - # instead of directing the URL to a CDN, we just point it to this static file - app.static( - "/simple-button.js", - str(JS_FIXTURES_DIR / "simple-button.js"), - content_type="text/javascript", - ) - - SimpleButton = reactpy.web.export( - reactpy.web.module_from_url("/simple-button.js", resolve_exports=False), - "SimpleButton", - ) - - @reactpy.component - def ShowSimpleButton(): - return SimpleButton({"id": "my-button"}) - - async with BackendFixture(app=app, implementation=sanic_implementation) as server: - async with DisplayFixture(server, browser) as display: - await display.show(ShowSimpleButton) - - await display.page.wait_for_selector("#my-button") - - -def test_module_from_template_where_template_does_not_exist(): - with pytest.raises(ValueError, match="No template for 'does-not-exist.js'"): - reactpy.web.module_from_template("does-not-exist", "something.js") - - -async def test_module_from_template(display: DisplayFixture): - victory = reactpy.web.module_from_template("react@18.2.0", "victory-bar@35.4.0") - - assert "react@18.2.0" in victory.file.read_text() - VictoryBar = reactpy.web.export(victory, "VictoryBar") - await display.show(VictoryBar) - - await display.page.wait_for_selector(".VictoryContainer") - - -async def test_module_from_file(display: DisplayFixture): - SimpleButton = reactpy.web.export( - reactpy.web.module_from_file( - "simple-button", JS_FIXTURES_DIR / "simple-button.js" - ), - "SimpleButton", - ) - - is_clicked = reactpy.Ref(False) - - @reactpy.component - def ShowSimpleButton(): - return SimpleButton( - {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} - ) - - await display.show(ShowSimpleButton) - - button = await display.page.wait_for_selector("#my-button") - await button.click() - await poll(lambda: is_clicked.current).until_is(True) - - -def test_module_from_file_source_conflict(tmp_path): - first_file = tmp_path / "first.js" - - with pytest.raises(FileNotFoundError, match="does not exist"): - reactpy.web.module_from_file("temp", first_file) - - first_file.touch() - - reactpy.web.module_from_file("temp", first_file) - - second_file = tmp_path / "second.js" - second_file.touch() - - # ok, same content - reactpy.web.module_from_file("temp", second_file) - - third_file = tmp_path / "third.js" - third_file.write_text("something-different") - - with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", third_file) - - -def test_web_module_from_file_symlink(tmp_path): - file = tmp_path / "temp.js" - file.touch() - - module = reactpy.web.module_from_file("temp", file, symlink=True) - - assert module.file.resolve().read_text() == "" - - file.write_text("hello world!") - - assert module.file.resolve().read_text() == "hello world!" - - -def test_web_module_from_file_symlink_twice(tmp_path): - file_1 = tmp_path / "temp_1.js" - file_1.touch() - - reactpy.web.module_from_file("temp", file_1, symlink=True) - - with assert_reactpy_did_not_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", file_1, symlink=True) - - file_2 = tmp_path / "temp_2.js" - file_2.write_text("something") - - with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", file_2, symlink=True) - - -def test_web_module_from_file_replace_existing(tmp_path): - file1 = tmp_path / "temp1.js" - file1.touch() - - reactpy.web.module_from_file("temp", file1) - - file2 = tmp_path / "temp2.js" - file2.write_text("something") - - with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_file("temp", file2) - - -def test_module_missing_exports(): - module = WebModule("test", NAME_SOURCE, None, {"a", "b", "c"}, None, False) - - with pytest.raises(ValueError, match="does not export 'x'"): - reactpy.web.export(module, "x") - - with pytest.raises(ValueError, match=r"does not export \['x', 'y'\]"): - reactpy.web.export(module, ["x", "y"]) - - -async def test_module_exports_multiple_components(display: DisplayFixture): - Header1, Header2 = reactpy.web.export( - reactpy.web.module_from_file( - "exports-two-components", JS_FIXTURES_DIR / "exports-two-components.js" - ), - ["Header1", "Header2"], - ) - - await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) - - await display.page.wait_for_selector("#my-h1", state="attached") - - await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) - - await display.page.wait_for_selector("#my-h2", state="attached") - - -async def test_imported_components_can_render_children(display: DisplayFixture): - module = reactpy.web.module_from_file( - "component-can-have-child", JS_FIXTURES_DIR / "component-can-have-child.js" - ) - Parent, Child = reactpy.web.export(module, ["Parent", "Child"]) - - await display.show( - lambda: Parent( - Child({"index": 1}), - Child({"index": 2}), - Child({"index": 3}), - ) - ) - - parent = await display.page.wait_for_selector("#the-parent", state="attached") - children = await parent.query_selector_all("li") - - assert len(children) == 3 - - for index, child in enumerate(children): - assert (await child.get_attribute("id")) == f"child-{index + 1}" - - -def test_module_from_string(): - reactpy.web.module_from_string("temp", "old") - with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.web.module_from_string("temp", "new") diff --git a/src/py/reactpy/tests/test_web/test_utils.py b/src/py/reactpy/tests/test_web/test_utils.py deleted file mode 100644 index 14c3e2e13..000000000 --- a/src/py/reactpy/tests/test_web/test_utils.py +++ /dev/null @@ -1,152 +0,0 @@ -from pathlib import Path - -import pytest -import responses - -from reactpy.testing import assert_reactpy_did_log -from reactpy.web.utils import ( - module_name_suffix, - resolve_module_exports_from_file, - resolve_module_exports_from_source, - resolve_module_exports_from_url, -) - -JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" - - -@pytest.mark.parametrize( - "name, suffix", - [ - ("module", ".js"), - ("module.ext", ".ext"), - ("module@x.y.z", ".js"), - ("module.ext@x.y.z", ".ext"), - ("@namespace/module", ".js"), - ("@namespace/module.ext", ".ext"), - ("@namespace/module@x.y.z", ".js"), - ("@namespace/module.ext@x.y.z", ".ext"), - ], -) -def test_module_name_suffix(name, suffix): - assert module_name_suffix(name) == suffix - - -@responses.activate -def test_resolve_module_exports_from_file(caplog): - responses.add( - responses.GET, - "https://some.external.url", - body="export {something as ExternalUrl}", - ) - path = JS_FIXTURES_DIR / "export-resolution" / "index.js" - assert resolve_module_exports_from_file(path, 4) == { - "Index", - "One", - "Two", - "ExternalUrl", - } - - -def test_resolve_module_exports_from_file_log_on_max_depth(caplog): - path = JS_FIXTURES_DIR / "export-resolution" / "index.js" - assert resolve_module_exports_from_file(path, 0) == set() - assert len(caplog.records) == 1 - assert caplog.records[0].message.endswith("max depth reached") - - caplog.records.clear() - - assert resolve_module_exports_from_file(path, 2) == {"Index", "One"} - assert len(caplog.records) == 1 - assert caplog.records[0].message.endswith("max depth reached") - - -def test_resolve_module_exports_from_file_log_on_unknown_file_location( - caplog, tmp_path -): - file = tmp_path / "some.js" - file.write_text("export * from './does-not-exist.js';") - resolve_module_exports_from_file(file, 2) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith( - "Did not resolve exports for unknown file" - ) - - -@responses.activate -def test_resolve_module_exports_from_url(): - responses.add( - responses.GET, - "https://some.url/first.js", - body="export const First = 1; export * from 'https://another.url/path/second.js';", - ) - responses.add( - responses.GET, - "https://another.url/path/second.js", - body="export const Second = 2; export * from '../third.js';", - ) - responses.add( - responses.GET, - "https://another.url/third.js", - body="export const Third = 3; export * from './fourth.js';", - ) - responses.add( - responses.GET, - "https://another.url/fourth.js", - body="export const Fourth = 4;", - ) - - assert resolve_module_exports_from_url("https://some.url/first.js", 4) == { - "First", - "Second", - "Third", - "Fourth", - } - - -def test_resolve_module_exports_from_url_log_on_max_depth(caplog): - assert resolve_module_exports_from_url("https://some.url", 0) == set() - assert len(caplog.records) == 1 - assert caplog.records[0].message.endswith("max depth reached") - - -def test_resolve_module_exports_from_url_log_on_bad_response(caplog): - assert resolve_module_exports_from_url("https://some.url", 1) == set() - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith("Did not resolve exports for url") - - -@pytest.mark.parametrize( - "text", - [ - "export default expression;", - "export default function (…) { … } // also class, function*", - "export default function name1(…) { … } // also class, function*", - "export { something as default };", - "export { default } from 'some-source';", - "export { something as default } from 'some-source';", - ], -) -def test_resolve_module_default_exports_from_source(text): - names, references = resolve_module_exports_from_source(text, exclude_default=False) - assert names == {"default"} and not references - - -def test_resolve_module_exports_from_source(): - fixture_file = JS_FIXTURES_DIR / "exports-syntax.js" - names, references = resolve_module_exports_from_source( - fixture_file.read_text(), exclude_default=False - ) - assert names == ( - {f"name{i}" for i in range(1, 21)} - | { - "functionName", - "ClassName", - } - ) and references == {"https://source1.com", "https://source2.com"} - - -def test_log_on_unknown_export_type(): - with assert_reactpy_did_log(match_message="Unknown export type "): - assert resolve_module_exports_from_source( - "export something unknown;", exclude_default=False - ) == (set(), set()) diff --git a/src/py/reactpy/tests/test_widgets.py b/src/py/reactpy/tests/test_widgets.py deleted file mode 100644 index d786fded0..000000000 --- a/src/py/reactpy/tests/test_widgets.py +++ /dev/null @@ -1,141 +0,0 @@ -from base64 import b64encode -from pathlib import Path - -import reactpy -from reactpy.testing import DisplayFixture, poll -from tests.tooling.common import DEFAULT_TYPE_DELAY - -HERE = Path(__file__).parent - - -IMAGE_SRC_BYTES = b""" -<svg width="400" height="110" xmlns="http://www.w3.org/2000/svg"> - <rect width="300" height="100" style="fill:rgb(0,0,255);" /> -</svg> -""" -BASE64_IMAGE_SRC = b64encode(IMAGE_SRC_BYTES).decode() - - -async def test_image_from_string(display: DisplayFixture): - src = IMAGE_SRC_BYTES.decode() - await display.show(lambda: reactpy.widgets.image("svg", src, {"id": "a-circle-1"})) - client_img = await display.page.wait_for_selector("#a-circle-1") - assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) - - -async def test_image_from_bytes(display: DisplayFixture): - src = IMAGE_SRC_BYTES - await display.show(lambda: reactpy.widgets.image("svg", src, {"id": "a-circle-1"})) - client_img = await display.page.wait_for_selector("#a-circle-1") - assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) - - -async def test_use_linked_inputs(display: DisplayFixture): - @reactpy.component - def SomeComponent(): - i_1, i_2 = reactpy.widgets.use_linked_inputs([{"id": "i_1"}, {"id": "i_2"}]) - return reactpy.html.div(i_1, i_2) - - await display.show(SomeComponent) - - input_1 = await display.page.wait_for_selector("#i_1") - input_2 = await display.page.wait_for_selector("#i_2") - - await input_1.type("hello", delay=DEFAULT_TYPE_DELAY) - - assert (await input_1.evaluate("e => e.value")) == "hello" - assert (await input_2.evaluate("e => e.value")) == "hello" - - await input_2.focus() - await input_2.type(" world", delay=DEFAULT_TYPE_DELAY) - - assert (await input_1.evaluate("e => e.value")) == "hello world" - assert (await input_2.evaluate("e => e.value")) == "hello world" - - -async def test_use_linked_inputs_on_change(display: DisplayFixture): - value = reactpy.Ref(None) - - @reactpy.component - def SomeComponent(): - i_1, i_2 = reactpy.widgets.use_linked_inputs( - [{"id": "i_1"}, {"id": "i_2"}], - on_change=value.set_current, - ) - return reactpy.html.div(i_1, i_2) - - await display.show(SomeComponent) - - input_1 = await display.page.wait_for_selector("#i_1") - input_2 = await display.page.wait_for_selector("#i_2") - - await input_1.type("hello", delay=DEFAULT_TYPE_DELAY) - - poll_value = poll(lambda: value.current) - - await poll_value.until_equals("hello") - - await input_2.focus() - await input_2.type(" world", delay=DEFAULT_TYPE_DELAY) - - await poll_value.until_equals("hello world") - - -async def test_use_linked_inputs_on_change_with_cast(display: DisplayFixture): - value = reactpy.Ref(None) - - @reactpy.component - def SomeComponent(): - i_1, i_2 = reactpy.widgets.use_linked_inputs( - [{"id": "i_1"}, {"id": "i_2"}], on_change=value.set_current, cast=int - ) - return reactpy.html.div(i_1, i_2) - - await display.show(SomeComponent) - - input_1 = await display.page.wait_for_selector("#i_1") - input_2 = await display.page.wait_for_selector("#i_2") - - await input_1.type("1") - - poll_value = poll(lambda: value.current) - - await poll_value.until_equals(1) - - await input_2.focus() - await input_2.type("2") - - await poll_value.until_equals(12) - - -async def test_use_linked_inputs_ignore_empty(display: DisplayFixture): - value = reactpy.Ref(None) - - @reactpy.component - def SomeComponent(): - i_1, i_2 = reactpy.widgets.use_linked_inputs( - [{"id": "i_1"}, {"id": "i_2"}], - on_change=value.set_current, - ignore_empty=True, - ) - return reactpy.html.div(i_1, i_2) - - await display.show(SomeComponent) - - input_1 = await display.page.wait_for_selector("#i_1") - input_2 = await display.page.wait_for_selector("#i_2") - - await input_1.type("1") - - poll_value = poll(lambda: value.current) - - await poll_value.until_equals("1") - - await input_2.focus() - await input_2.press("Backspace") - - await poll_value.until_equals("1") - - await input_2.type("2") - - await poll_value.until_equals("2") diff --git a/src/py/reactpy/tests/tooling/asserts.py b/src/py/reactpy/tests/tooling/asserts.py deleted file mode 100644 index ac84aa0ba..000000000 --- a/src/py/reactpy/tests/tooling/asserts.py +++ /dev/null @@ -1,5 +0,0 @@ -def assert_same_items(left, right): - """Check that two unordered sequences are equal (only works if reprs are equal)""" - sorted_left = sorted(left, key=repr) - sorted_right = sorted(right, key=repr) - assert sorted_left == sorted_right diff --git a/src/py/reactpy/tests/tooling/common.py b/src/py/reactpy/tests/tooling/common.py deleted file mode 100644 index c0191bd4e..000000000 --- a/src/py/reactpy/tests/tooling/common.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Any - -from reactpy.core.types import LayoutEventMessage, LayoutUpdateMessage - -# see: https://github.com/microsoft/playwright-python/issues/1614 -DEFAULT_TYPE_DELAY = 100 # milliseconds - - -def event_message(target: str, *data: Any) -> LayoutEventMessage: - return {"type": "layout-event", "target": target, "data": data} - - -def update_message(path: str, model: Any) -> LayoutUpdateMessage: - return {"type": "layout-update", "path": path, "model": model} diff --git a/src/py/reactpy/tests/tooling/hooks.py b/src/py/reactpy/tests/tooling/hooks.py deleted file mode 100644 index 1926a93bc..000000000 --- a/src/py/reactpy/tests/tooling/hooks.py +++ /dev/null @@ -1,15 +0,0 @@ -from reactpy.core.hooks import current_hook, use_state - - -def use_force_render(): - return current_hook().schedule_render - - -def use_toggle(init=False): - state, set_state = use_state(init) - return state, lambda: set_state(lambda old: not old) - - -def use_counter(initial_value): - state, set_state = use_state(initial_value) - return state, lambda: set_state(lambda old: old + 1) diff --git a/src/py/reactpy/tests/tooling/loop.py b/src/py/reactpy/tests/tooling/loop.py deleted file mode 100644 index f9e100981..000000000 --- a/src/py/reactpy/tests/tooling/loop.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -import threading -import time -from asyncio import wait_for -from collections.abc import Iterator -from contextlib import contextmanager - -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT - - -@contextmanager -def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: - """Open a new event loop and cleanly stop it - - Args: - as_current: whether to make this loop the current loop in this thread - """ - loop = asyncio.new_event_loop() - try: - if as_current: - asyncio.set_event_loop(loop) - loop.set_debug(True) - yield loop - finally: - try: - _cancel_all_tasks(loop, as_current) - if as_current: - loop.run_until_complete( - wait_for( - loop.shutdown_asyncgens(), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - loop.run_until_complete( - wait_for( - loop.shutdown_default_executor(), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - finally: - if as_current: - asyncio.set_event_loop(None) - start = time.time() - while loop.is_running(): - if (time.time() - start) > REACTPY_TESTING_DEFAULT_TIMEOUT.current: - msg = f"Failed to stop loop after {REACTPY_TESTING_DEFAULT_TIMEOUT.current} seconds" - raise TimeoutError(msg) - time.sleep(0.1) - loop.close() - - -def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None: - to_cancel = asyncio.all_tasks(loop) - if not to_cancel: - return - - done = threading.Event() - count = len(to_cancel) - - def one_task_finished(future): - nonlocal count - count -= 1 - if count == 0: - done.set() - - for task in to_cancel: - loop.call_soon_threadsafe(task.cancel) - task.add_done_callback(one_task_finished) - - if is_current: - loop.run_until_complete( - wait_for( - asyncio.gather(*to_cancel, return_exceptions=True), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - elif not done.wait(timeout=3): # user was responsible for cancelling all tasks - msg = "Could not stop event loop in time" - raise TimeoutError(msg) - - for task in to_cancel: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "unhandled exception during event loop shutdown", - "exception": task.exception(), - "task": task, - } - ) diff --git a/tasks.py b/tasks.py deleted file mode 100644 index e19476202..000000000 --- a/tasks.py +++ /dev/null @@ -1,426 +0,0 @@ -from __future__ import annotations - -import json -import logging -import os -import re -import sys -from dataclasses import dataclass -from pathlib import Path -from shutil import rmtree -from typing import TYPE_CHECKING, Any, Callable - -import semver -import toml -from invoke import task -from invoke.context import Context -from invoke.exceptions import Exit - -# --- Typing Preamble ------------------------------------------------------------------ - - -if TYPE_CHECKING: - # not available in typing module until Python 3.8 - # not available in typing module until Python 3.10 - from typing import Literal, Protocol, TypeAlias - - class ReleasePrepFunc(Protocol): - def __call__( - self, context: Context, package: PackageInfo - ) -> Callable[[bool], None]: - ... - - LanguageName: TypeAlias = "Literal['py', 'js']" - - -# --- Constants ------------------------------------------------------------------------ - - -log = logging.getLogger(__name__) -log.setLevel("INFO") -log_handler = logging.StreamHandler(sys.stdout) -log_handler.setFormatter(logging.Formatter("%(message)s")) -log.addHandler(log_handler) - - -# --- Constants ------------------------------------------------------------------------ - - -ROOT = Path(__file__).parent -DOCS_DIR = ROOT / "docs" -SRC_DIR = ROOT / "src" -JS_DIR = SRC_DIR / "js" -PY_DIR = SRC_DIR / "py" -PY_PROJECTS = [p for p in PY_DIR.iterdir() if (p / "pyproject.toml").exists()] -TAG_PATTERN = re.compile( - # start - r"^" - # package name - r"(?P<name>[0-9a-zA-Z-@/]+)-" - # package version - r"v(?P<version>[0-9][0-9a-zA-Z-\.\+]*)" - # end - r"$" -) - - -# --- Tasks ---------------------------------------------------------------------------- - - -@task -def env(context: Context): - """Install development environment""" - env_py(context) - env_js(context) - - -@task -def env_py(context: Context): - """Install Python development environment""" - for py_proj in PY_PROJECTS: - py_proj_toml = toml.load(py_proj / "pyproject.toml") - hatch_default_env = py_proj_toml["tool"]["hatch"]["envs"].get("default", {}) - hatch_default_features = hatch_default_env.get("features", []) - hatch_default_deps = hatch_default_env.get("dependencies", []) - with context.cd(py_proj): - context.run(f"pip install '.[{','.join(hatch_default_features)}]'") - context.run(f"pip install {' '.join(map(repr, hatch_default_deps))}") - - -@task -def env_js(context: Context): - """Install JS development environment""" - in_js( - context, - "npm ci", - "npm run build", - hide="out", - ) - - -@task -def lint_py(context: Context, fix: bool = False): - """Run linters and type checkers""" - if fix: - context.run("ruff --fix .") - else: - context.run("ruff .") - context.run("black --check --diff .") - in_py( - context, - f"flake8 --toml-config '{ROOT / 'pyproject.toml'}' .", - "hatch run lint:all", - ) - - -@task(pre=[env_js]) -def lint_js(context: Context, fix: bool = False): - """Run linters and type checkers""" - if fix: - in_js(context, "npm run fix:format") - else: - in_js(context, "npm run check:format") - in_js(context, "npm run check:types") - - -@task -def test_py(context: Context, no_cov: bool = False): - """Run test suites""" - in_py( - context, - f"hatch run {'test' if no_cov else 'cov'} --maxfail=3 --reruns=3", - ) - - -@task(pre=[env_js]) -def test_js(context: Context): - """Run test suites""" - in_js(context, "npm run check:tests") - - -@task(pre=[env_py]) -def test_docs(context: Context): - with context.cd(DOCS_DIR): - context.run("poetry install") - context.run( - "poetry run sphinx-build " - "-a " # re-write all output files - "-T " # show full tracebacks - "-W " # turn warnings into errors - "--keep-going " # complete the build, but still report warnings as errors - "-b doctest " - "source " - "build", - ) - context.run("poetry run sphinx-build -b doctest source build") - - context.run("docker build . --file ./docs/Dockerfile") - - -@task -def docs(context: Context, docker: bool = False): - """Build documentation""" - if docker: - _docker_docs(context) - else: - _live_docs(context) - - -def _docker_docs(context: Context) -> None: - context.run("docker build . --file ./docs/Dockerfile --tag reactpy-docs:latest") - context.run( - "docker run -it -p 5000:5000 -e DEBUG=1 --rm reactpy-docs:latest", pty=True - ) - - -def _live_docs(context: Context) -> None: - with context.cd(DOCS_DIR): - context.run("poetry install") - context.run( - "poetry run python main.py " - "--open-browser " - # watch python source too - "--watch=../src/py " - # for some reason this matches absolute paths - "--ignore=**/_auto/* " - "--ignore=**/_static/custom.js " - "--ignore=**/node_modules/* " - "--ignore=**/package-lock.json " - "-a " - "-E " - "-b " - "html " - "source " - "build" - ) - - -@task -def publish(context: Context, dry_run: str = ""): - """Publish packages that have been tagged for release in the current commit - - To perform a test run use `--dry-run=<name>-v<version>` to specify a comma-separated - list of tags to simulate a release of. For example, to simulate a release of - `@foo/bar-v1.2.3` and `baz-v4.5.6` use `--dry-run=@foo/bar-v1.2.3,baz-v4.5.6`. - """ - packages = get_packages(context) - - release_prep: dict[LanguageName, ReleasePrepFunc] = { - "js": prepare_js_release, - "py": prepare_py_release, - } - - parsed_tags: list[TagInfo] = [ - parse_tag(tag) for tag in dry_run.split(",") or get_current_tags(context) - ] - - publishers: list[Callable[[bool], None]] = [] - for tag_info in parsed_tags: - if tag_info.name not in packages: - msg = f"Tag {tag_info.tag} references package {tag_info.name} that does not exist" - raise Exit(msg) - - pkg_info = packages[tag_info.name] - if pkg_info.version != tag_info.version: - msg = f"Tag {tag_info.tag} references version {tag_info.version} of package {tag_info.name}, but the current version is {pkg_info.version}" - raise Exit(msg) - - log.info(f"Preparing {tag_info.name} for release...") - publishers.append(release_prep[pkg_info.language](context, pkg_info)) - - for publish in publishers: - publish(bool(dry_run)) - - -# --- Utilities ------------------------------------------------------------------------ - - -def in_py(context: Context, *commands: str, **kwargs: Any) -> None: - for p in PY_PROJECTS: - with context.cd(p): - log.info(f"Running commands in {p}...") - for c in commands: - context.run(c, **kwargs) - - -def in_js(context: Context, *commands: str, **kwargs: Any) -> None: - with context.cd(JS_DIR): - for c in commands: - context.run(c, **kwargs) - - -def get_packages(context: Context) -> dict[str, PackageInfo]: - packages: list[PackageInfo] = [] - - for maybe_pkg in PY_DIR.glob("*"): - if (maybe_pkg / "pyproject.toml").exists(): - packages.append(make_py_pkg_info(context, maybe_pkg)) - else: - msg = f"unexpected dir or file: {maybe_pkg}" - raise Exit(msg) - - packages_dir = JS_DIR / "packages" - for maybe_pkg in packages_dir.glob("*"): - if (maybe_pkg / "package.json").exists(): - packages.append(make_js_pkg_info(maybe_pkg)) - elif maybe_pkg.is_dir(): - for maybe_ns_pkg in maybe_pkg.glob("*"): - if (maybe_ns_pkg / "package.json").exists(): - packages.append(make_js_pkg_info(maybe_ns_pkg)) - else: - msg = f"unexpected dir or file: {maybe_pkg}" - raise Exit(msg) - - packages_by_name = {p.name: p for p in packages} - if len(packages_by_name) != len(packages): - raise Exit("duplicate package names detected") - - return packages_by_name - - -def make_py_pkg_info(context: Context, pkg_dir: Path) -> PackageInfo: - with context.cd(pkg_dir): - proj_metadata = json.loads(context.run("hatch project metadata").stdout) - return PackageInfo( - name=proj_metadata["name"], - path=pkg_dir, - language="py", - version=proj_metadata["version"], - ) - - -def make_js_pkg_info(pkg_dir: Path) -> PackageInfo: - with (pkg_dir / "package.json").open() as f: - pkg_json = json.load(f) - return PackageInfo( - name=pkg_json["name"], - path=pkg_dir, - language="js", - version=pkg_json["version"], - ) - - -@dataclass -class PackageInfo: - name: str - path: Path - language: LanguageName - version: str - - -def get_current_tags(context: Context) -> set[str]: - """Get tags for the current commit""" - # check if unstaged changes - try: - context.run("git diff --cached --exit-code", hide=True) - context.run("git diff --exit-code", hide=True) - except Exception: - log.error("Cannot create a tag - there are uncommitted changes") - return set() - - tags_per_commit: dict[str, list[str]] = {} - for commit, tag in map( - str.split, - context.run( - r"git for-each-ref --format '%(objectname) %(refname:short)' refs/tags", - hide=True, - ).stdout.splitlines(), - ): - tags_per_commit.setdefault(commit, []).append(tag) - - current_commit = context.run( - "git rev-parse HEAD", silent=True, external=True - ).stdout.strip() - tags = set(tags_per_commit.get(current_commit, set())) - - if not tags: - log.error("No tags found for current commit") - - for t in tags: - if not TAG_PATTERN.match(t): - msg = f"Invalid tag: {t}" - raise Exit(msg) - - log.info(f"Found tags: {tags}") - - return tags - - -def parse_tag(tag: str) -> TagInfo: - match = TAG_PATTERN.match(tag) - if not match: - msg = f"Invalid tag: {tag}" - raise Exit(msg) - - version = match.group("version") - if not semver.Version.is_valid(version): - raise Exit(f"Invalid version: {version} in tag {tag}") - - return TagInfo(tag=tag, name=match.group("name"), version=match.group("version")) - - -@dataclass -class TagInfo: - tag: str - name: str - version: str - - -def prepare_js_release( - context: Context, package: PackageInfo -) -> Callable[[bool], None]: - node_auth_token = os.getenv("NODE_AUTH_TOKEN") - if node_auth_token is None: - msg = "NODE_AUTH_TOKEN environment variable must be set" - raise Exit(msg) - - with context.cd(JS_DIR): - context.run("npm ci") - context.run("npm run build") - - def publish(dry_run: bool) -> None: - with context.cd(JS_DIR): - if dry_run: - context.run(f"npm --workspace {package.name} pack --dry-run") - return - context.run( - f"npm --workspace {package.name} publish --access public", - env={"NODE_AUTH_TOKEN": node_auth_token}, - ) - - return publish - - -def prepare_py_release( - context: Context, package: PackageInfo -) -> Callable[[bool], None]: - twine_username = os.getenv("PYPI_USERNAME") - twine_password = os.getenv("PYPI_PASSWORD") - - if not (twine_password and twine_username): - msg = "PYPI_USERNAME and PYPI_PASSWORD environment variables must be set" - raise Exit(msg) - - for build_dir_name in ["build", "dist"]: - build_dir_path = Path.cwd() / build_dir_name - if build_dir_path.exists(): - rmtree(str(build_dir_path)) - - with context.cd(package.path): - context.run("hatch build") - - def publish(dry_run: bool): - with context.cd(package.path): - if dry_run: - context.run("twine check dist/*") - return - - context.run( - "twine upload dist/*", - env_dict={ - "TWINE_USERNAME": twine_username, - "TWINE_PASSWORD": twine_password, - }, - ) - - return publish