use-form
A UI agnostic hook for handling local form state.
Installation
npm i @polaritybit/use-form
Basic Usage
You can initialize a new form state by calling the useForm
hook and passing it an initial state. If no initial state is passed, an empty object will be used:
import { useForm } from '@polaritybit/use-form'
type Person = {
name: string
surname: string
}
function MyForm() {
const [data, setData] = useForm<Person>({})
}
The hook will return the current form state and a setter function. The setter function can be used to update the form state at any time, in different ways.
Updating state programmatically
Calling the setter function with a delta object will update the specified portion of the state accordingly:
function handleUpdateName(name: string) {
setData({ name: name })
}
By passing a second parameter set to true
the state will be replaced completely instead:
function MyForm({ person }) {
const [data, setData] = useForm<Person>(person)
useEffect(() => {
setData(person, true)
}, [person])
}
The setter function can also be called by passing the key you want to update, with its new value as the second parameter:
function handleUpdateName(name: string) {
setData('name', name)
}
Updating the state from an input
The setter function can also be passed to any regular onChange
event, and it will update the state whenever the event is fired:
function MyForm() {
const [data, setData] = useForm<Person>(person)
return (
<div>
<input name="name" value={data.name} onChange={setData} />
<input name="surname" value={data.surname} onChange={setData} />
</div>
)
}
When passing the setter function to an onChange
event, remember to also set the name
property of the input, as it's used internally to determine which part of the state needs to be updated!
Nested State
If your state has nested objects, the setter function can be used to update nested properties by using the dot notation:
type Person = {
name: string
surname: string
address: {
city: string
street: string
zip: number
}
}
function MyForm() {
const [data, setData] = useForm<Person>({})
return (
<div>
<input name="name" value={data.name} onChange={setData} />
<input name="surname" value={data.surname} onChange={setData} />
<input name="address.city" value={data.address?.city} onChange={setData} />
</div>
)
}
The setter function can also receive a nested property name as the key when calling it with directly:
function handleUpdateCity(city: string) {
setData('address.city', city)
}
You can also use the useNestedForm
hook to focus on a nested portion of the state and obtain a reference to that portion and a setter function that acts on that nested portion of the state (and all nested portions inside it).
useNestedForm
requires the parent form tuple as its first parameter, and the
path of the nested state as the second (dot notation is supported):
type Person = {
name: string
surname: string
address: {
city: string
street: string
zip: number
}
}
function MyForm() {
const [data, setData] = useForm<Person>({})
const [address, setAddress] = useNestedForm([data, setData], 'address')
return (
<div>
<input name="name" value={data.name} onChange={setData} />
<input name="surname" value={data.surname} onChange={setData} />
<input name="city" value={address.city} onChange={setAddress} />
</div>
)
}
useNestedForm
can be used recursively to focus on deep portions of the state, as many times as required:
type Person = {
name: string
surname: string
address: {
city: string
street: string
zip: number
contacts: {
phone: number
email: string
}
}
}
function MyForm() {
const [data, setData] = useForm<Person>({})
const [address, setAddress] = useNestedForm([data, setData], 'address')
const [contacts, setContacts] = useNestedForm([address, setAddress], 'contacts')
return (
<div>
<input name="name" value={data.name} onChange={setData} />
<input name="surname" value={data.surname} onChange={setData} />
<input name="city" value={address.city} onChange={setAddress} />
<input name="email" value={contacts.email} onChange={setContacts} />
</div>
)
}
The setter function returned by useNestedForm
has the same signature of the setter function returned by useForm
and can be used in the same way programmatically:
function handleUpdateCity(city: string) {
setAddress({
city: city,
})
setAddress('city', city)
setAddress({}, true)
}
Lists
If your state has a nested array, you can use the useFormList
hook to focus on it and interact with it:
type Todo = {
id: number
text: string
completed: boolean
}
type Person = {
name: string
todos: Todo[]
}
function MyForm() {
const [data, setData] = useForm<Person>({})
const [todos, todosHandlers] = useFormList([data, setData], 'todos', (i) => i.id)
}
The useFormList
receives the tuple of the parent form (it can be either the main form or a nested one), the path to the array (dot notation is supported) and an identifier function that returns a unique property for each item in the array. This function can but should not be the identity function (i => i
) unless your array contains primitive values such as strings or numbers.
The useFormList
hook returns a reference to the array, as well as an object containing three handlers to interact with the list: onAdd
, onRemove
, onEdit
.
The onAdd
handler can be used to add items to the array:
function handleAddTodo(text: string) {
onAdd({ id: Math.random(), text, completed: false })
}
The onRemove
handler can be used to remove items from the array:
function handleRemoveTodo(todo: Todo) {
onRemove(todo)
}
The onEdit
handler can be used to update an item in the array. The signature is similar to that of the setter functions of the other two hooks, but with an additional starting argument that indicates the item that needs to be updated:
function handleUpdateTodo(todo: Todo, text: string) {
onEdit(todo, { text: text })
onEdit(todo, { id: todo.id, text: text, completed: false }, true)
onEdit(todo, 'text', text)
}
The onEdit
function can be bound to a specific item and passed to any onChange
event as well:
function TodoListItem({ todo, onEdit, onRemove }) {
const boundOnEdit = onEdit.bind(undefined, todo)
return (
<tr>
<td>
<input name="text" value={todo.text} onChange={boundOnEdit} />
</td>
<td>
<input type="checkbox" name="completed" checked={todo.completed} onChange={boundOnEdit} />
</td>
<td>
<button onClick={() => onRemove(todo)}>Remove</button>
</td>
</tr>
)
}
function MyForm() {
const [data, setData] = useForm<Person>({})
const [todos, { onEdit, onRemove }] = useFormList([data, setData], 'todos', (i) => i.id)
return (
<table>
<thead>
<tr>
<th>Text</th>
<th>Done</th>
<th></th>
</tr>
</thead>
<tbody>
{todos.map((todo) => (
<TodoListItem key={todo.id} todo={todo} onEdit={onEdit} onRemove={onRemove} />
))}
</tbody>
</table>
)
}
Middlewares
The useForm
hook supports a middleware as a second parameter. A middleware is a function that gets the next value of the state before it is set, and can mutate it before returning it:
const [data, setData] = useForm<Person>({}, (data) => {
data.name = data.name?.toUpperCase() ?? ''
return data
})
The middleware will be called at the end of every update, including those triggered by the useNestedForm
or useFormList
hooks!
Miscellaneous
The value
helper
Setting an empty form state and using the setter function to handle onChange
events will probably fill your console with warnings about how you are making uncontrolled components controlled. To avoid this, either set a complete initial state or use the value
helper to wrap all your value
props:
<input name="whatever" value={value(data.whatever)} onChange={setData} />
The value
helper will return the value or an empty string if the value is either null
or undefined
.
Dot Notation
You can use dot notation to traverse nested properties in the state. This includes items within an array, so all these
uses are valid:
setData('address.contacts.email', 'hello@example.com')
setData('todos.0.completed', true)
What about validation?
This library focuses on local form state management, and validation is out of its scope. Since you always have access to your form's state, you can use any validation library to validate it either after every update or before submitting. joi is an excellent choice:
function MyForm({ onSubmit }) {
const [data, setData] = useForm()
const [validationStatus, setValidationStatus] = useState(null)
function handleSubmit() {
const { value, error } = Joi.object({
name: Joi.string().required(),
}).validate(data)
if (!error) {
setValidationStatus(null)
onSubmit(data)
} else {
setValidationStatus(error)
}
}
}
Is this library maintained?
This is a rewrite of a custom hook I've been using in many projects since hooks came out in beta. I have evolved it throughout the years and fixed many bugs, but the rewrite, which was mainly me wanting to have consistent typings, may have introduced some issues (TypeScript can be hard!). I don't plan on evolving this beyond what's already here unless I find a feature that really needs to be added, so unless there are bugs don't expect many updates.
If you find something that breaks and/or have ideas on additional features, I'll be happy to hear about it in the issues or through a PR! :)
Previous Work
- react-attire was my attempt at doing something similar using render props. It was much more limited but it did its job, even though I haven't used it since hooks have become available.
- react-ui-formalize was my first attempt at tackling the "form" issue, using higher order components. Very old, very deprecated...
License
MIT