├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── lock.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── banner.png ├── package-lock.json ├── package-scripts.js ├── package.json ├── rollup.config.js ├── scripts └── postinstall.js ├── src ├── index.d.ts ├── index.js ├── internal │ ├── shallowEqual.js │ └── shallowEqual.test.js ├── useField.js ├── useField.test.js ├── useForm.js ├── useForm.test.js ├── useFormState.js └── useFormState.test.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "loose": true, 7 | "targets": { 8 | "node": "8" 9 | } 10 | } 11 | ], 12 | "@babel/preset-react" 13 | ], 14 | 15 | "plugins": [ 16 | "@babel/plugin-syntax-dynamic-import", 17 | "@babel/plugin-syntax-import-meta", 18 | "@babel/plugin-proposal-class-properties", 19 | "@babel/plugin-proposal-json-strings", 20 | [ 21 | "@babel/plugin-proposal-decorators", 22 | { 23 | "legacy": true 24 | } 25 | ], 26 | "@babel/plugin-proposal-function-sent", 27 | "@babel/plugin-proposal-export-namespace-from", 28 | "@babel/plugin-proposal-numeric-separator", 29 | "@babel/plugin-proposal-throw-expressions" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "plugins": ["react-hooks"], 4 | "rules": { 5 | "jsx-a11y/href-no-hash": 0, 6 | "react-hooks/rules-of-hooks": "error", 7 | "react-hooks/exhaustive-deps": "warn" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at rasmussenerik@gmail.com. The project 59 | team will review and investigate all complaints, and will respond in a way that 60 | it deems appropriate to the circumstances. The project team is obligated to 61 | maintain confidentiality with regard to the reporter of an incident. Further 62 | details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to 🏁 React Final Form Hooks! Please take a 4 | moment to review this document **before submitting a pull request**. 5 | 6 | We are open to, and grateful for, any contributions made by the community. 7 | 8 | ## Reporting issues and asking questions 9 | 10 | Before opening an issue, please search 11 | the [issue tracker](https://github.com/final-form/react-final-form-hooks/issues) to 12 | make sure your issue hasn’t already been reported. 13 | 14 | **We use the issue tracker to keep track of bugs and improvements** to 🏁 React 15 | Final Form Hooks itself, its examples, and the documentation. We encourage you to open 16 | issues to discuss improvements, architecture, internal implementation, etc. If a 17 | topic has been discussed before, we will ask you to join the previous 18 | discussion. 19 | 20 | For support or usage questions, please search and ask on 21 | [StackOverflow with a `react-final-form-hooks` tag](https://stackoverflow.com/questions/tagged/react-final-form-hooks). 22 | We ask you to do this because StackOverflow has a much better job at keeping 23 | popular questions visible. Unfortunately good answers get lost and outdated on 24 | GitHub. 25 | 26 | **If you already asked at StackOverflow and still got no answers, post an issue 27 | with the question link, so we can either answer it or evolve into a bug/feature 28 | request.** 29 | 30 | ## Sending a pull request 31 | 32 | **Please ask first before starting work on any significant new features.** 33 | 34 | It's never a fun experience to have your pull request declined after investing a 35 | lot of time and effort into a new feature. To avoid this from happening, we 36 | request that contributors create 37 | [an issue](https://github.com/final-form/react-final-form-hooks/issues) to first 38 | discuss any significant new features. 39 | 40 | Please try to keep your pull request focused in scope and avoid including 41 | unrelated commits. 42 | 43 | After you have submitted your pull request, we’ll try to get back to you as soon 44 | as possible. We may suggest some changes or improvements. 45 | 46 | Please format the code before submitting your pull request by running: 47 | 48 | ```sh 49 | npm run precommit 50 | ``` 51 | 52 | ## Coding standards 53 | 54 | Our code formatting rules are defined in 55 | [.eslintrc](https://github.com/final-form/react-final-form-hooks/blob/master/.eslintrc). 56 | You can check your code against these standards by running: 57 | 58 | ```sh 59 | npm start lint 60 | ``` 61 | 62 | To automatically fix any style violations in your code, you can run: 63 | 64 | ```sh 65 | npm run precommit 66 | ``` 67 | 68 | ## Running tests 69 | 70 | You can run the test suite using the following commands: 71 | 72 | ```sh 73 | npm test 74 | ``` 75 | 76 | Please ensure that the tests are passing when submitting a pull request. If 77 | you're adding new features to 🏁 React Final Form Hooks, please include tests. 78 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: erikras 4 | patreon: erikras 5 | open_collective: final-form 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ### Are you submitting a **bug report** or a **feature request**? 8 | 9 | 10 | 11 | ### What is the current behavior? 12 | 13 | 14 | 15 | ### What is the expected behavior? 16 | 17 | ### Sandbox Link 18 | 19 | 20 | 21 | ### What's your environment? 22 | 23 | 24 | 25 | ### Other information 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for lock-threads - https://github.com/dessant/lock-threads 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 365 5 | 6 | # Issues and pull requests with these labels will not be locked. Set to `[]` to disable 7 | exemptLabels: [] 8 | 9 | # Label to add before locking, such as `outdated`. Set to `false` to disable 10 | lockLabel: false 11 | 12 | # Comment to post before locking. Set to `false` to disable 13 | lockComment: > 14 | This thread has been automatically locked since there has not been 15 | any recent activity after it was closed. Please open a new issue for 16 | related bugs. 17 | 18 | # Limit to only `issues` or `pulls` 19 | # only: issues 20 | 21 | # Optionally, specify configuration settings just for `issues` or `pulls` 22 | # issues: 23 | # exemptLabels: 24 | # - help-wanted 25 | # lockLabel: outdated 26 | 27 | pulls: 28 | daysUntilLock: 30 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.iml 3 | .nyc_output 4 | coverage 5 | flow-coverage 6 | node_modules 7 | dist 8 | lib 9 | es 10 | npm-debug.log 11 | .DS_Store 12 | yarn.lock 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | trailingComma: none 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | before_install: 4 | - npm install -g npm@6.7.0 5 | cache: 6 | directories: 7 | - node_modules 8 | notifications: 9 | email: false 10 | node_js: 11 | - '9' 12 | - '10' 13 | - 'stable' 14 | script: 15 | - npm start validate 16 | after_success: 17 | - npx codecov 18 | branches: 19 | only: 20 | - master -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Erik Rasmussen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏁 React Final Form Hooks 2 | 3 | ![React Final Form Hooks](banner.png) 4 | 5 | [![Backers on Open Collective](https://opencollective.com/final-form/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/final-form/sponsors/badge.svg)](#sponsors) [![NPM Version](https://img.shields.io/npm/v/react-final-form-hooks.svg?style=flat)](https://www.npmjs.com/package/react-final-form-hooks) 6 | [![NPM Downloads](https://img.shields.io/npm/dm/react-final-form-hooks.svg?style=flat)](https://www.npmjs.com/package/react-final-form-hooks) 7 | [![Build Status](https://travis-ci.org/final-form/react-final-form-hooks.svg?branch=master)](https://travis-ci.org/final-form/react-final-form-hooks) 8 | [![codecov.io](https://codecov.io/gh/final-form/react-final-form-hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/final-form/react-final-form-hooks) 9 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 10 | 11 | ✅ Zero dependencies 12 | 13 | ✅ Only peer dependencies: React and 14 | [🏁 Final Form](https://github.com/final-form/final-form#-final-form) 15 | 16 | ✅ Opt-in subscriptions - only update on the state you need! 17 | 18 | ✅ 💥 [**1.2 kB gzipped**](https://bundlephobia.com/result?p=react-final-form-hooks) 💥 19 | 20 | --- 21 | 22 | ## Installation 23 | 24 | ```bash 25 | npm install --save react-final-form-hooks final-form 26 | ``` 27 | 28 | or 29 | 30 | ```bash 31 | yarn add react-final-form-hooks final-form 32 | ``` 33 | 34 | ## Getting Started 35 | 36 | 🏁 React Final Form Hooks is the leanest possible way to connect 🏁 Final Form to React, to acheive subscriptions-based form state management using the [Observer pattern](https://en.wikipedia.org/wiki/Observer_pattern). 37 | 38 | #### ⚠️ This library will re-render your entire form on every state change, _as you type_. ⚠️ 39 | 40 | If performance is your goal, you are recommended to use [🏁 React Final Form](https://github.com/final-form/react-final-form). Also, that library does many other things for you, like managing checkbox and radio buttons properly. RFFHooks leaves all of that work to you. By default, 🏁 React Final Form Hooks subscribes to _all_ changes, but if you want to fine tune your form, you may specify only the form state that you care about for rendering your gorgeous UI. 41 | 42 | Here's what it looks like in your code: 43 | 44 | ```jsx 45 | import { useForm, useField } from 'react-final-form-hooks' 46 | 47 | const MyForm = () => { 48 | const { form, handleSubmit, values, pristine, submitting } = useForm({ 49 | onSubmit, // the function to call with your form values upon valid submit 50 | validate // a record-level validation function to check all form values 51 | }) 52 | const firstName = useField('firstName', form) 53 | const lastName = useField('lastName', form) 54 | return ( 55 |
56 |
57 | 58 | 59 | {firstName.meta.touched && firstName.meta.error && ( 60 | {firstName.meta.error} 61 | )} 62 |
63 |
64 | 65 | 66 | {lastName.meta.touched && lastName.meta.error && ( 67 | {lastName.meta.error} 68 | )} 69 |
70 | 73 |
74 | ) 75 | } 76 | ``` 77 | 78 | ## Table of Contents 79 | 80 | 81 | 82 | 83 | 84 | - [What's the difference between `react-final-form-hooks` and the hooks introduced in `react-final-form v5`?](#whats-the-difference-between-react-final-form-hooks-and-the-hooks-introduced-in-react-final-form-v5) 85 | - [Examples](#examples) 86 | - [Simple Example](#simple-example) 87 | - [API](#api) 88 | - [`useField`](#usefield) 89 | - [`name : string`](#name--string) 90 | - [`form : Form`](#form--form) 91 | - [`validate? : (value:any) => any`](#validate--valueany--any) 92 | - [`subscription? : FieldSubscription`](#subscription--fieldsubscription) 93 | - [`useForm`](#useform) 94 | - [`onSubmit : (values:Object) => ?Object | Promise | void`](#onsubmit--valuesobject--object--promiseobject--void) 95 | - [`validate?: (values:Object) => Object | Promise`](#validate-valuesobject--object--promiseobject) 96 | - [Contributors](#contributors) 97 | - [Backers](#backers) 98 | - [Sponsors](#sponsors) 99 | 100 | 101 | 102 | ## What's the difference between `react-final-form-hooks` and the hooks introduced in `react-final-form v5`? 103 | 104 | Great question. The TL;DR is this: 105 | 106 | - `react-final-form-hooks` is a lightweight, simple solution for quickly getting a form up and running _in a single render function_, but allows for no performance optimization. 107 | - `react-final-form v5` is a more robust, battle-tested solution that involves creating more components and structure around your form. 108 | 109 | `react-final-form-hooks` does not put the `form` instance into the React context, but rather forces you to pass the `form` instance to `useField` so that the field can register itself with the form. This allows you to create your entire form in a single functional component, like the `MyForm` example above. It will also, by necessity, rerender your entire form on every value change. 110 | 111 | `react-final-form v5` requires that you wrap your entire form in a `
` component that provides the `form` instance via context to its descendants. This means that you cannot use `useField` in the same function that is rendering your ``, because `useField` must be inside the ``. 112 | 113 | **Conclusion**: If your app has a couple of small (< 20 inputs) forms where you aren't doing anything fancy with reusable custom input components, `react-final-form-hooks` might be all you need. But if your app is bigger and more sophisticated, or you need to optimize for performance, you should probably use `react-final-form`. 114 | 115 | ## Examples 116 | 117 | ### [Simple Example](https://codesandbox.io/s/r4j042m694) 118 | 119 | Shows how to create fields and attach them to `` elements. 120 | 121 | ## API 122 | 123 | The following can be imported from `react-final-form-hooks`. 124 | 125 | ### `useField` 126 | 127 | Returns an object similar to [`FieldRenderProps`](https://github.com/final-form/react-final-form#fieldrenderprops). 128 | 129 | `useField` takes four parameters: 130 | 131 | #### `name : string` 132 | 133 | > The name of the field. Required. 134 | 135 | #### `form : Form` 136 | 137 | > The object returned from `useForm`. Required. 138 | 139 | #### `validate? : (value:any) => any` 140 | 141 | > A field-level validation function that takes the current value and returns `undefined` if it is valid, or the error if it is not. Optional. 142 | 143 | #### `subscription? : FieldSubscription` 144 | 145 | > A subscription of which parts of field state to be notified about. See [`FieldSubscription`](https://github.com/final-form/final-form#fieldsubscription--string-boolean-). Optional. 146 | 147 | ### `useForm` 148 | 149 | Returns an object similar to [`FormRenderProps`](https://github.com/final-form/react-final-form#formrenderprops). 150 | 151 | `useForm` takes two parameters: 152 | 153 | #### `onSubmit : (values:Object) => ?Object | Promise | void` 154 | 155 | See [🏁 Final Form's `onSubmit` docs](https://github.com/final-form/final-form#onsubmit-values-object-form-formapi-callback-errors-object--void--object--promiseobject--void) for more information. Required. 156 | 157 | #### `validate?: (values:Object) => Object | Promise` 158 | 159 | A record level validation function. See [🏁 Final Form's `validate` docs](https://github.com/final-form/final-form#validate-values-object--object--promiseobject) for more information. Optional. 160 | 161 | --- 162 | 163 | ## Contributors 164 | 165 | This project exists thanks to all the people who contribute. [[Contribute](.github/CONTRIBUTING.md)]. 166 | 167 | 168 | ## Backers 169 | 170 | Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/final-form#backer)] 171 | 172 | 173 | 174 | ## Sponsors 175 | 176 | Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/final-form#sponsor)] 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/final-form/react-final-form-hooks/f273d6b1d0dfa7990d2693ff416db8dbc2d04e64/banner.png -------------------------------------------------------------------------------- /package-scripts.js: -------------------------------------------------------------------------------- 1 | const npsUtils = require('nps-utils') 2 | 3 | const series = npsUtils.series 4 | const concurrent = npsUtils.concurrent 5 | const rimraf = npsUtils.rimraf 6 | 7 | module.exports = { 8 | scripts: { 9 | test: { 10 | default: 'jest --coverage --detectOpenHandles', 11 | watch: 'jest --coverage --watch' 12 | }, 13 | size: { 14 | description: 'check the size of the bundle', 15 | script: 'bundlesize' 16 | }, 17 | build: { 18 | description: 'delete the dist directory and run all builds', 19 | default: series( 20 | rimraf('dist'), 21 | concurrent.nps( 22 | 'build.es', 23 | 'build.cjs', 24 | 'build.umd.main', 25 | 'build.umd.min', 26 | 'copyTypes' 27 | ) 28 | ), 29 | es: { 30 | description: 'run the build with rollup (uses rollup.config.js)', 31 | script: 'rollup --config --environment FORMAT:es' 32 | }, 33 | cjs: { 34 | description: 'run rollup build with CommonJS format', 35 | script: 'rollup --config --environment FORMAT:cjs' 36 | }, 37 | umd: { 38 | min: { 39 | description: 'run the rollup build with sourcemaps', 40 | script: 'rollup --config --sourcemap --environment MINIFY,FORMAT:umd' 41 | }, 42 | main: { 43 | description: 'builds the cjs and umd files', 44 | script: 'rollup --config --sourcemap --environment FORMAT:umd' 45 | } 46 | }, 47 | andTest: series.nps('build', 'size') 48 | }, 49 | copyTypes: series(npsUtils.copy('src/*.d.ts dist')), 50 | docs: { 51 | description: 'Generates table of contents in README', 52 | script: 'doctoc README.md' 53 | }, 54 | lint: { 55 | description: 'lint the entire project', 56 | script: 'eslint .' 57 | }, 58 | typescript: { 59 | description: 'typescript check the entire project', 60 | script: 'tsc' 61 | }, 62 | validate: { 63 | description: 64 | 'This runs several scripts to make sure things look good before committing or on clean install', 65 | default: concurrent.nps('lint', 'build.andTest', 'typescript', 'test') 66 | } 67 | }, 68 | options: { 69 | silent: false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-final-form-hooks", 3 | "version": "2.0.2", 4 | "description": "React Hooks to bind to 🏁 Final Form's high performance subscription-based form state management engine", 5 | "main": "dist/react-final-form-hooks.cjs.js", 6 | "jsnext:main": "dist/react-final-form-hooks.es.js", 7 | "module": "dist/react-final-form-hooks.es.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "scripts" 12 | ], 13 | "scripts": { 14 | "start": "nps", 15 | "doctoc": "doctoc README.md && doctoc docs/faq.md && prettier --write README.md && prettier --write docs/faq.md", 16 | "precommit": "lint-staged && npm start validate", 17 | "prepublish": "npm start validate", 18 | "postinstall": "node ./scripts/postinstall.js || exit 0", 19 | "test": "NODE_ENV=test nps test" 20 | }, 21 | "author": "Erik Rasmussen (http://github.com/erikras)", 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/final-form/react-final-form-hooks.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/final-form/react-final-form-hooks/issues" 29 | }, 30 | "homepage": "https://github.com/final-form/react-final-form-hooks#readme", 31 | "devDependencies": { 32 | "@babel/core": "^7.1.2", 33 | "@babel/plugin-proposal-class-properties": "^7.0.0", 34 | "@babel/plugin-proposal-decorators": "^7.0.0", 35 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0", 36 | "@babel/plugin-proposal-function-sent": "^7.0.0", 37 | "@babel/plugin-proposal-json-strings": "^7.0.0", 38 | "@babel/plugin-proposal-numeric-separator": "^7.0.0", 39 | "@babel/plugin-proposal-throw-expressions": "^7.0.0", 40 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 41 | "@babel/plugin-syntax-import-meta": "^7.0.0", 42 | "@babel/preset-env": "^7.0.0", 43 | "@babel/preset-react": "^7.0.0", 44 | "@types/react": "^16.8.2", 45 | "babel-core": "^7.0.0-bridge.0", 46 | "babel-eslint": "^10.0.1", 47 | "babel-jest": "^24.1.0", 48 | "bundlesize": "^0.18.0", 49 | "cryptiles": ">=4.1.2", 50 | "doctoc": "^1.3.0", 51 | "eslint": "^5.13.0", 52 | "eslint-config-react-app": "^3.0.3", 53 | "eslint-plugin-babel": "^5.2.1", 54 | "eslint-plugin-flowtype": "^3.2.0", 55 | "eslint-plugin-import": "^2.16.0", 56 | "eslint-plugin-jsx-a11y": "^6.2.1", 57 | "eslint-plugin-react": "^7.11.1", 58 | "eslint-plugin-react-hooks": "^1.6.0", 59 | "fast-deep-equal": "^2.0.1", 60 | "final-form": "^4.15.0", 61 | "hoek": ">=4.2.1", 62 | "husky": "^1.1.1", 63 | "jest": "^24.5.0", 64 | "lint-staged": "^8.1.3", 65 | "merge": ">=1.2.1", 66 | "nps": "^5.9.3", 67 | "nps-utils": "^1.7.0", 68 | "opencollective": "^1.0.3", 69 | "prettier": "^1.16.4", 70 | "prettier-eslint-cli": "^4.7.1", 71 | "prop-types": "^15.6.2", 72 | "react": "^16.8.1", 73 | "react-dom": "^16.8.1", 74 | "react-hooks-testing-library": "^0.4.0", 75 | "rollup": "^1.1.2", 76 | "rollup-plugin-babel": "^4.0.1", 77 | "rollup-plugin-commonjs": "^9.2.0", 78 | "rollup-plugin-node-resolve": "^4.0.0", 79 | "rollup-plugin-replace": "^2.1.0", 80 | "rollup-plugin-uglify": "^6.0.2", 81 | "tslint": "^5.11.0", 82 | "typescript": "^3.3.1" 83 | }, 84 | "peerDependencies": { 85 | "final-form": "^4.7.3", 86 | "prop-types": "^15.6.2", 87 | "react": "^16.8.1" 88 | }, 89 | "lint-staged": { 90 | "*.{js*,ts*,json,md,css}": [ 91 | "prettier --write", 92 | "git add" 93 | ] 94 | }, 95 | "jest": { 96 | "testEnvironment": "jsdom" 97 | }, 98 | "bundlesize": [ 99 | { 100 | "path": "dist/react-final-form-hooks.umd.min.js", 101 | "threshold": "2kB" 102 | }, 103 | { 104 | "path": "dist/react-final-form-hooks.es.js", 105 | "threshold": "3kB" 106 | }, 107 | { 108 | "path": "dist/react-final-form-hooks.cjs.js", 109 | "threshold": "3kB" 110 | } 111 | ], 112 | "collective": { 113 | "type": "opencollective", 114 | "url": "https://opencollective.com/final-form" 115 | }, 116 | "dependencies": {} 117 | } 118 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import babel from 'rollup-plugin-babel' 3 | import commonjs from 'rollup-plugin-commonjs' 4 | import { uglify } from 'rollup-plugin-uglify' 5 | import replace from 'rollup-plugin-replace' 6 | import pkg from './package.json' 7 | 8 | const minify = process.env.MINIFY 9 | const format = process.env.FORMAT 10 | const es = format === 'es' 11 | const umd = format === 'umd' 12 | const cjs = format === 'cjs' 13 | 14 | let output 15 | 16 | if (es) { 17 | output = { file: `dist/react-final-form-hooks.es.js`, format: 'es' } 18 | } else if (umd) { 19 | if (minify) { 20 | output = { 21 | file: `dist/react-final-form-hooks.umd.min.js`, 22 | format: 'umd' 23 | } 24 | } else { 25 | output = { file: `dist/react-final-form-hooks.umd.js`, format: 'umd' } 26 | } 27 | } else if (cjs) { 28 | output = { file: `dist/react-final-form-hooks.cjs.js`, format: 'cjs' } 29 | } else if (format) { 30 | throw new Error(`invalid format specified: "${format}".`) 31 | } else { 32 | throw new Error('no format specified. --environment FORMAT:xxx') 33 | } 34 | 35 | export default { 36 | input: 'src/index.js', 37 | output: Object.assign( 38 | { 39 | name: 'react-final-form-hooks', 40 | exports: 'named', 41 | globals: { 42 | react: 'React', 43 | 'prop-types': 'PropTypes', 44 | 'final-form': 'FinalForm' 45 | } 46 | }, 47 | output 48 | ), 49 | external: umd 50 | ? Object.keys(pkg.peerDependencies || {}) 51 | : [ 52 | ...Object.keys(pkg.dependencies || {}), 53 | ...Object.keys(pkg.peerDependencies || {}) 54 | ], 55 | plugins: [ 56 | resolve({ jsnext: true, main: true }), 57 | commonjs({ include: 'node_modules/**' }), 58 | babel({ 59 | exclude: 'node_modules/**', 60 | babelrc: false, 61 | presets: [ 62 | [ 63 | '@babel/preset-env', 64 | { 65 | loose: true, 66 | modules: false 67 | } 68 | ], 69 | '@babel/preset-react' 70 | ], 71 | plugins: [ 72 | '@babel/plugin-syntax-dynamic-import', 73 | '@babel/plugin-syntax-import-meta', 74 | '@babel/plugin-proposal-class-properties', 75 | '@babel/plugin-proposal-json-strings', 76 | [ 77 | '@babel/plugin-proposal-decorators', 78 | { 79 | legacy: true 80 | } 81 | ], 82 | '@babel/plugin-proposal-function-sent', 83 | '@babel/plugin-proposal-export-namespace-from', 84 | '@babel/plugin-proposal-numeric-separator', 85 | '@babel/plugin-proposal-throw-expressions' 86 | ] 87 | }), 88 | umd 89 | ? replace({ 90 | 'process.env.NODE_ENV': JSON.stringify( 91 | minify ? 'production' : 'development' 92 | ) 93 | }) 94 | : null, 95 | minify ? uglify() : null 96 | ].filter(Boolean) 97 | } 98 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable */ 3 | /* Adapted from nodemon's postinstall: https://github.com/remy/nodemon/blob/master/package.json */ 4 | 5 | var msg = 6 | 'Use react-final-form-hooks at work? Consider supporting our development efforts at opencollective.com/final-form' 7 | 8 | console.log(msg) 9 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FormApi, 3 | Config, 4 | FormState, 5 | FormSubscription, 6 | FieldSubscription, 7 | FieldState, 8 | FieldValidator 9 | } from 'final-form' 10 | 11 | export interface FormRenderProps extends FormState { 12 | form: FormApi 13 | handleSubmit: ( 14 | event?: React.SyntheticEvent 15 | ) => Promise | undefined 16 | } 17 | 18 | interface FormConfig extends Config { 19 | subscription?: FormSubscription 20 | initialValuesEqual?: (a: object, b: object) => boolean 21 | } 22 | 23 | type NonFunctionPropertyNames = { 24 | [K in keyof T]: T[K] extends Function ? never : K 25 | }[keyof T] 26 | type NonFunctionProperties = Pick> 27 | 28 | export interface FieldRenderProps { 29 | input: { 30 | name: string 31 | onBlur: (event?: React.FocusEvent) => void 32 | onChange: (event: React.ChangeEvent | V) => void 33 | onFocus: (event?: React.FocusEvent) => void 34 | value: V 35 | checked?: boolean 36 | } 37 | meta: NonFunctionProperties> 38 | } 39 | 40 | declare module 'react-final-form-hooks' { 41 | export function useForm(config: FormConfig): FormRenderProps 42 | export function useFormState( 43 | form: FormApi, 44 | subscription?: FormSubscription 45 | ): FormRenderProps 46 | 47 | export function useField( 48 | name: string, 49 | form: FormApi, 50 | validate?: FieldValidator, 51 | subscription?: FieldSubscription 52 | ): FieldRenderProps 53 | } 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as useField } from './useField' 2 | export { default as useForm } from './useForm' 3 | export { default as useFormState } from './useFormState' 4 | -------------------------------------------------------------------------------- /src/internal/shallowEqual.js: -------------------------------------------------------------------------------- 1 | const shallowEqual = (a, b) => { 2 | if (a === b) { 3 | return true 4 | } 5 | if (typeof a !== 'object' || !a || typeof b !== 'object' || !b) { 6 | return false 7 | } 8 | const keysA = Object.keys(a) 9 | const keysB = Object.keys(b) 10 | if (keysA.length !== keysB.length) { 11 | return false 12 | } 13 | const bHasOwnProperty = Object.prototype.hasOwnProperty.bind(b) 14 | for (let idx = 0; idx < keysA.length; idx++) { 15 | const key = keysA[idx] 16 | if (!bHasOwnProperty(key) || a[key] !== b[key]) { 17 | return false 18 | } 19 | } 20 | return true 21 | } 22 | 23 | export default shallowEqual 24 | -------------------------------------------------------------------------------- /src/internal/shallowEqual.test.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from './shallowEqual' 2 | 3 | describe('shallowEqual', function() { 4 | beforeEach(() => { 5 | // isolated instances of shallowEqual for each test. 6 | jest.resetModules() 7 | }) 8 | 9 | // test cases copied from https://github.com/facebook/fbjs/blob/82247de1c33e6f02a199778203643eaee16ea4dc/src/core/__tests__/shallowEst.js 10 | it('returns false if either argument is null', () => { 11 | expect(shallowEqual(null, {})).toEqual(false) 12 | expect(shallowEqual({}, null)).toEqual(false) 13 | }) 14 | 15 | it('returns true if both arguments are null or undefined', () => { 16 | expect(shallowEqual(null, null)).toEqual(true) 17 | expect(shallowEqual(undefined, undefined)).toEqual(true) 18 | }) 19 | 20 | it('returns true if arguments are shallow equal', () => { 21 | expect(shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })).toEqual( 22 | true 23 | ) 24 | }) 25 | 26 | it('returns false if arguments are not objects and not equal', () => { 27 | expect(shallowEqual(1, 2)).toEqual(false) 28 | }) 29 | 30 | it('returns false if only one argument is not an object', () => { 31 | expect(shallowEqual(1, {})).toEqual(false) 32 | }) 33 | 34 | it('returns false if first argument has too many keys', () => { 35 | expect(shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toEqual(false) 36 | }) 37 | 38 | it('returns false if second argument has too many keys', () => { 39 | expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toEqual(false) 40 | }) 41 | 42 | it('returns true if values are not primitives but are ===', () => { 43 | let obj = {} 44 | expect( 45 | shallowEqual({ a: 1, b: 2, c: obj }, { a: 1, b: 2, c: obj }) 46 | ).toEqual(true) 47 | }) 48 | 49 | // subsequent test cases are copied from lodash tests 50 | it('returns false if arguments are not shallow equal', () => { 51 | expect(shallowEqual({ a: 1, b: 2, c: {} }, { a: 1, b: 2, c: {} })).toEqual( 52 | false 53 | ) 54 | }) 55 | 56 | it('should handle comparisons if `customizer` returns `undefined`', () => { 57 | const noop = () => void 0 58 | 59 | expect(shallowEqual('a', 'a', noop)).toEqual(true) 60 | expect(shallowEqual(['a'], ['a'], noop)).toEqual(true) 61 | expect(shallowEqual({ '0': 'a' }, { '0': 'a' }, noop)).toEqual(true) 62 | }) 63 | 64 | it('should treat objects created by `Object.create(null)` like any other plain object', () => { 65 | function Foo() { 66 | this.a = 1 67 | } 68 | Foo.prototype.constructor = null 69 | 70 | const object2 = { a: 1 } 71 | expect(shallowEqual(new Foo(), object2)).toEqual(true) 72 | 73 | const object1 = Object.create(null) 74 | object1.a = 1 75 | expect(shallowEqual(object1, object2)).toEqual(true) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/useField.js: -------------------------------------------------------------------------------- 1 | import { fieldSubscriptionItems } from 'final-form' 2 | import { useEffect, useRef, useState } from 'react' 3 | 4 | export const all = fieldSubscriptionItems.reduce((result, key) => { 5 | result[key] = true 6 | return result 7 | }, {}) 8 | 9 | const subscriptionToInputs = subscription => 10 | fieldSubscriptionItems.map(key => Boolean(subscription[key])) 11 | 12 | const eventValue = event => { 13 | if (!event || !event.target) { 14 | return event 15 | } else if (event.target.type === 'checkbox') { 16 | return event.target.checked 17 | } 18 | 19 | return event.target.value 20 | } 21 | 22 | const useField = (name, form, validate, subscription = all) => { 23 | const autoFocus = useRef(false) 24 | const validatorRef = useRef(undefined) 25 | const [state, setState] = useState({}) 26 | 27 | validatorRef.current = validate 28 | 29 | const deps = subscriptionToInputs(subscription) 30 | useEffect( 31 | () => 32 | form.registerField( 33 | name, 34 | newState => { 35 | if (autoFocus.current) { 36 | autoFocus.current = false 37 | setTimeout(() => newState.focus()) 38 | } 39 | setState(newState) 40 | }, 41 | subscription, 42 | validate 43 | ? { 44 | getValidator: () => validatorRef.current 45 | } 46 | : undefined 47 | ), 48 | // eslint-disable-next-line react-hooks/exhaustive-deps 49 | [name, form, ...deps] 50 | ) 51 | let { blur, change, focus, value, ...meta } = state 52 | delete meta.name // it's in input 53 | return { 54 | input: { 55 | name, 56 | value: value === undefined ? '' : value, 57 | onBlur: () => state.blur(), 58 | onChange: event => state.change(eventValue(event)), 59 | onFocus: () => { 60 | if (state.focus) { 61 | state.focus() 62 | } else { 63 | autoFocus.current = true 64 | } 65 | } 66 | }, 67 | meta 68 | } 69 | } 70 | 71 | export default useField 72 | -------------------------------------------------------------------------------- /src/useField.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { act } from 'react-dom/test-utils' 4 | import { renderHook, cleanup } from 'react-hooks-testing-library' 5 | import useField, { all } from './useField' 6 | import useForm from './useForm' 7 | 8 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) 9 | 10 | describe('useField()', () => { 11 | let form, name, subscription, container 12 | 13 | beforeEach(() => { 14 | name = 'foo' 15 | subscription = { value: true } 16 | container = document.createElement('div') 17 | document.body.appendChild(container) 18 | }) 19 | afterEach(() => { 20 | document.body.removeChild(container) 21 | container = null 22 | cleanup() 23 | }) 24 | 25 | describe("form hook parameter's registerField", () => { 26 | beforeEach(() => { 27 | form = { 28 | registerField: jest.fn() 29 | } 30 | }) 31 | 32 | it('is called with correct params', () => { 33 | renderHook(() => useField(name, form, undefined, subscription)) 34 | 35 | expect(form.registerField).toHaveBeenCalledWith( 36 | name, 37 | expect.any(Function), 38 | subscription, 39 | undefined 40 | ) 41 | }) 42 | 43 | it('receives all subscriptions by default', () => { 44 | renderHook(() => useField(name, form)) 45 | 46 | expect(form.registerField).toHaveBeenCalledWith( 47 | name, 48 | expect.any(Function), 49 | all, 50 | undefined 51 | ) 52 | }) 53 | }) 54 | 55 | describe('field input props return object', () => { 56 | let value, blur, change, focus 57 | 58 | const setupHook = () => { 59 | const { result } = renderHook(() => useField(name, form, subscription)) 60 | const { input } = result.current 61 | 62 | return input 63 | } 64 | 65 | beforeEach(() => { 66 | value = 'bar' 67 | blur = jest.fn() 68 | change = jest.fn(() => { 69 | debugger 70 | }) 71 | focus = jest.fn() 72 | 73 | form = { 74 | registerField: jest.fn((name, callback, subscription) => 75 | callback({ blur, change, focus, value }) 76 | ) 77 | } 78 | }) 79 | 80 | it('has the correct name', () => { 81 | const { name: returnName } = setupHook() 82 | 83 | expect(returnName).toBe(name) 84 | }) 85 | 86 | it('has the correct value', () => { 87 | const { value: returnValue } = setupHook() 88 | 89 | expect(returnValue).toBe(value) 90 | }) 91 | 92 | describe('onBlur()', () => { 93 | it('calls the correct event handler', () => { 94 | const { onBlur } = setupHook() 95 | 96 | onBlur() 97 | 98 | expect(blur).toHaveBeenCalled() 99 | }) 100 | }) 101 | 102 | describe('onChange()', () => { 103 | describe('when event is not an usual input event', () => { 104 | const event = { foo: 'bar' } 105 | 106 | it('calls the provided handler with event object', () => { 107 | const { onChange } = setupHook() 108 | 109 | onChange(event) 110 | 111 | expect(change).toHaveBeenCalledWith(event) 112 | }) 113 | }) 114 | 115 | describe('when event has a value prop', () => { 116 | const event = { target: { value: 'foo' } } 117 | 118 | it('calls provided handler with value', () => { 119 | const { onChange } = setupHook() 120 | 121 | onChange(event) 122 | 123 | expect(change).toHaveBeenLastCalledWith(event.target.value) 124 | }) 125 | }) 126 | 127 | describe('when event has a checked prop', () => { 128 | const event = { target: { type: 'checkbox', checked: false } } 129 | 130 | it('calls provided handler with value', () => { 131 | const { onChange } = setupHook() 132 | 133 | onChange(event) 134 | 135 | expect(change).toHaveBeenLastCalledWith(event.target.checked) 136 | }) 137 | }) 138 | }) 139 | 140 | describe('onFocus()', () => { 141 | it('calls the correct event handler', () => { 142 | const { onFocus } = setupHook() 143 | 144 | onFocus() 145 | 146 | expect(focus).toHaveBeenCalled() 147 | }) 148 | }) 149 | }) 150 | 151 | describe('field meta return object', () => { 152 | let meta 153 | 154 | beforeEach(() => { 155 | meta = { name: 'foo', bar: 'bar', biz: 'biz' } 156 | 157 | form = { 158 | registerField: jest.fn((name, callback, subscription) => 159 | callback({ ...meta }) 160 | ) 161 | } 162 | }) 163 | 164 | it('has the correct values', () => { 165 | const { result } = renderHook(() => useField(name, form, subscription)) 166 | const { meta: returnMeta } = result.current 167 | 168 | delete meta.name 169 | 170 | expect(returnMeta).toEqual(meta) 171 | }) 172 | }) 173 | 174 | describe('autofocus', () => { 175 | it('set focused when autoFocus is used on input', async () => { 176 | const onSubmit = jest.fn() 177 | const focusState = jest.fn() 178 | const FormComponent = () => { 179 | const { form, handleSubmit } = useForm({ onSubmit }) 180 | const firstName = useField('firstName', form, undefined, { 181 | active: true 182 | }) 183 | focusState(firstName.meta) 184 | return ( 185 | 186 | 187 | 188 | 189 | 190 | ) 191 | } 192 | act(async () => { 193 | ReactDOM.render(, container) 194 | }) 195 | 196 | await sleep(1) 197 | 198 | expect(onSubmit).not.toHaveBeenCalled() 199 | expect(focusState).toHaveBeenCalledTimes(4) 200 | expect(focusState).toHaveBeenLastCalledWith({ active: true }) 201 | }) 202 | }) 203 | 204 | describe('field level validation', () => { 205 | it('should not call form validation if field validationallow validate field', () => { 206 | const FIELD_NAME = 'firstName' 207 | 208 | const onSubmit = jest.fn() 209 | const validate = jest.fn(values => { 210 | let errors = {} 211 | if (values[FIELD_NAME] && values[FIELD_NAME].length <= 4) { 212 | errors[FIELD_NAME] = 'Must be at least 4 chars' 213 | } 214 | return errors 215 | }) 216 | 217 | const required = jest.fn(value => (value ? undefined : 'Required')) 218 | 219 | const FormComponent = () => { 220 | const { form, handleSubmit } = useForm({ 221 | onSubmit, 222 | validate 223 | }) 224 | const firstName = useField(FIELD_NAME, form, value => required(value)) 225 | 226 | return ( 227 |
228 | 229 | 230 | {firstName.meta.touched && firstName.meta.error && ( 231 | {firstName.meta.error} 232 | )} 233 | 234 |
235 | ) 236 | } 237 | 238 | act(() => { 239 | ReactDOM.render(, container) 240 | }) 241 | 242 | expect(validate).toHaveBeenCalledTimes(2) 243 | expect(required).toHaveBeenCalledTimes(1) 244 | 245 | // span is not in dom because field error is not raised 246 | expect(container.querySelector('span')).toBe(null) 247 | 248 | // submit 249 | const button = container.querySelector('button') 250 | act(() => { 251 | button.dispatchEvent(new MouseEvent('click', { bubbles: true })) 252 | }) 253 | 254 | // span has required error 255 | expect(container.querySelector('span').innerHTML).toBe('Required') 256 | // form validate function has not been called 257 | expect(validate).toHaveBeenCalledTimes(2) 258 | // onSubmit has not been called in any moment 259 | expect(onSubmit).not.toHaveBeenCalled() 260 | 261 | // why this is not updated again? 262 | expect(required).toHaveBeenCalledTimes(1) 263 | }) 264 | }) 265 | }) 266 | -------------------------------------------------------------------------------- /src/useForm.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useEffect } from 'react' 2 | import { createForm, configOptions } from 'final-form' 3 | import useFormState from './useFormState' 4 | import shallowEqual from './internal/shallowEqual' 5 | 6 | // https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily 7 | const useMemoOnce = factory => { 8 | const ref = useRef() 9 | 10 | if (!ref.current) { 11 | ref.current = factory() 12 | } 13 | 14 | return ref.current 15 | } 16 | 17 | const useForm = ({ 18 | subscription, 19 | initialValuesEqual = shallowEqual, 20 | ...config 21 | }) => { 22 | const form = useMemoOnce(() => createForm(config)) 23 | const prevConfig = useRef(config) 24 | const state = useFormState(form, subscription) 25 | const handleSubmit = useCallback( 26 | event => { 27 | if (event) { 28 | if (typeof event.preventDefault === 'function') { 29 | event.preventDefault() 30 | } 31 | if (typeof event.stopPropagation === 'function') { 32 | event.stopPropagation() 33 | } 34 | } 35 | return form.submit() 36 | }, 37 | [form] 38 | ) 39 | 40 | useEffect(() => { 41 | if (config === prevConfig.current) { 42 | return 43 | } 44 | 45 | if ( 46 | config.initialValues && 47 | !initialValuesEqual( 48 | config.initialValues, 49 | prevConfig.current.initialValues 50 | ) 51 | ) { 52 | form.initialize(config.initialValues) 53 | } 54 | 55 | configOptions.forEach(key => { 56 | if (key !== 'initialValues' && config[key] !== prevConfig.current[key]) { 57 | form.setConfig(key, config[key]) 58 | } 59 | }) 60 | 61 | prevConfig.current = config 62 | }) 63 | 64 | return { ...state, form, handleSubmit } 65 | } 66 | 67 | export default useForm 68 | -------------------------------------------------------------------------------- /src/useForm.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, cleanup, act } from 'react-hooks-testing-library' 2 | import useForm from './useForm' 3 | 4 | describe('useForm()', () => { 5 | let defaultConfig 6 | 7 | beforeEach(() => { 8 | defaultConfig = { 9 | onSubmit: jest.fn() 10 | } 11 | }) 12 | afterEach(cleanup) 13 | 14 | const setup = overrideConfig => { 15 | const initialProps = { 16 | ...defaultConfig, 17 | ...overrideConfig 18 | } 19 | const { result, rerender } = renderHook(props => useForm(props), { 20 | initialProps 21 | }) 22 | const { handleSubmit, form } = result.current 23 | 24 | return { handleSubmit, form, rerender, result } 25 | } 26 | 27 | describe('handleSubmit()', () => { 28 | it('causes form to submit', () => { 29 | const { handleSubmit } = setup() 30 | 31 | act(() => handleSubmit()) 32 | 33 | expect(defaultConfig.onSubmit).toHaveBeenCalled() 34 | }) 35 | 36 | describe('when event has preventDefault()', () => { 37 | let event 38 | 39 | beforeEach(() => { 40 | event = { preventDefault: jest.fn() } 41 | }) 42 | 43 | it('calls prevent default', () => { 44 | const { handleSubmit } = setup() 45 | 46 | act(() => handleSubmit(event)) 47 | 48 | expect(event.preventDefault).toHaveBeenCalled() 49 | }) 50 | }) 51 | 52 | describe('when event has stopPropagation()', () => { 53 | let event 54 | 55 | beforeEach(() => { 56 | event = { stopPropagation: jest.fn() } 57 | }) 58 | 59 | it('calls prevent default', () => { 60 | const { handleSubmit } = setup() 61 | 62 | act(() => handleSubmit(event)) 63 | 64 | expect(event.stopPropagation).toHaveBeenCalled() 65 | }) 66 | }) 67 | }) 68 | 69 | describe('form config', () => { 70 | describe('initialValues', () => { 71 | const overrideConfig = { initialValues: { foo: 'foo' } } 72 | 73 | it('is set to initialValues provided in config object', () => { 74 | const { form } = setup(overrideConfig) 75 | const { initialValues } = form.getState() 76 | 77 | expect(initialValues).toEqual(overrideConfig.initialValues) 78 | }) 79 | 80 | it('can be changed', () => { 81 | const nextConfig = { initialValues: { bar: 'bar' } } 82 | const { rerender, result } = setup(overrideConfig) 83 | 84 | act(() => rerender({ ...defaultConfig, ...nextConfig })) 85 | const { form } = result.current 86 | const { initialValues } = form.getState() 87 | 88 | expect(initialValues).toEqual(nextConfig.initialValues) 89 | }) 90 | }) 91 | 92 | describe('other configuration', () => { 93 | const overrideConfig = { mutators: { foo: () => 'foo' } } 94 | 95 | it('is set to configuration provided in config object', () => { 96 | const { form } = setup(overrideConfig) 97 | 98 | expect(form.mutators.hasOwnProperty('foo')).toBe(true) 99 | }) 100 | 101 | it('can be changed', () => { 102 | const nextConfig = { mutators: { bar: () => 'bar' } } 103 | const { rerender, result } = setup(overrideConfig) 104 | 105 | act(() => rerender({ ...defaultConfig, ...nextConfig })) 106 | const { form } = result.current 107 | 108 | expect(form.mutators.hasOwnProperty('foo')).toBe(false) 109 | expect(form.mutators.hasOwnProperty('bar')).toBe(true) 110 | }) 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/useFormState.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { formSubscriptionItems } from 'final-form' 3 | 4 | export const all = formSubscriptionItems.reduce((result, key) => { 5 | result[key] = true 6 | return result 7 | }, {}) 8 | 9 | /** 10 | * Converts { active: true, data: false, ... } to `[true, false, false, ...]`. 11 | */ 12 | const subscriptionToInputs = subscription => 13 | formSubscriptionItems.map(key => Boolean(subscription[key])) 14 | 15 | const useFormState = (form, subscription = all) => { 16 | const [state, setState] = useState(() => form.getState()) 17 | 18 | const deps = subscriptionToInputs(subscription) 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | useEffect(() => form.subscribe(setState, subscription), [form, ...deps]) 21 | 22 | return state 23 | } 24 | 25 | export default useFormState 26 | -------------------------------------------------------------------------------- /src/useFormState.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, cleanup, act } from 'react-hooks-testing-library' 2 | import useFormState, { all } from './useFormState' 3 | 4 | describe('useFormState()', () => { 5 | let form, formState, setFormState, subscription 6 | 7 | beforeEach(() => { 8 | subscription = { value: true } 9 | formState = { foo: 'foo' } 10 | form = { 11 | getState: jest.fn(() => formState), 12 | subscribe: jest.fn((setState, subscription) => (setFormState = setState)) 13 | } 14 | }) 15 | afterEach(cleanup) 16 | 17 | describe('form state', () => { 18 | it('comes from form parameter', () => { 19 | const { result } = renderHook(() => useFormState(form, subscription)) 20 | const returnState = result.current 21 | 22 | expect(returnState).toBe(formState) 23 | }) 24 | }) 25 | 26 | describe('subscription array', () => { 27 | it('defaults to all subscriptions', () => { 28 | renderHook(() => useFormState(form)) 29 | 30 | expect(form.subscribe).toHaveBeenCalledWith(expect.any(Function), all) 31 | }) 32 | }) 33 | 34 | describe("form's subscribe function", () => { 35 | it('is called with the subscrition array', () => { 36 | renderHook(() => useFormState(form, subscription)) 37 | 38 | expect(form.subscribe).toHaveBeenCalledWith( 39 | expect.any(Function), 40 | subscription 41 | ) 42 | }) 43 | 44 | it('receives a callback to update form state', () => { 45 | const nextState = { bar: 'bar' } 46 | const { result } = renderHook(() => useFormState(form, subscription)) 47 | 48 | act(() => { 49 | setFormState(nextState) 50 | }) 51 | const returnState = result.current 52 | 53 | expect(returnState).toBe(nextState) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015", "dom"], 4 | "baseUrl": ".", 5 | "noEmit": true, 6 | "strict": true 7 | }, 8 | "include": ["./src/**/*"] 9 | } 10 | --------------------------------------------------------------------------------