This article was originally posted on the personal site of one of our MojoTech engineers, Isaiah Thomason. The content and subject matter is relevant to the work we do here at MojoTech, and Isaiah agreed to let us cross-post the article here in it's entirety with minor edits. Enjoy.
As I've said in a previous article, it's more than possible to handle forms in React without using state. But what about formatted inputs? Let's say I have an input that's intended to take a person's phone number (or some other meaningful numeric value). You're probably used to seeing solutions that look something like this:
import React, { useState } from "react";function MyPage() {const [myNumericValue, setMyNumericValue] = useState("");function handleChange(event: Event & React.ChangeEvent<HTMLInputElement>) {const { value } = event.target;if (/^\d*$/.test(value)) setMyNumericValue(value);}return (<form><input id="some-numeric-input" type="text" value={myNumericValue} onChange={handleChange} /></form>);}
(If you want, you can follow along in a codesandbox as you read this article. This codesandbox starts you off with the code you see above.)
Okay... This works... But this brings us back to the problem of needing state variables to handle our inputs. The problem is worse if we have several formatted inputs. If you're like me and you don't want to inherit all of the disadvantages of relying on state for your forms, there is another way...
import React from "react";function MyPage() {/** Tracks the last valid value of the input. */let lastValidValue: string;/** Helps ensure that the last valid value is maintained in the input. */function handleBeforeInput(event: React.FormEvent<HTMLInputElement>) {lastValidValue = (event.target as HTMLInputElement).value;}/** Allows the user to input correctly-formatted values. Blocks invalid inputs. */function handleChange(event: React.ChangeEvent<HTMLInputElement>) {const { value, selectionStart } = event.target;// Invalid input valueif (!/^\d*$/.test(value)) {event.target.value = lastValidValue;// Keep the cursor in the right location if the user input was invalidconst cursorPlace = (selectionStart as number) - (value.length - event.target.value.length);requestAnimationFrame(() => event.target.setSelectionRange(cursorPlace, cursorPlace));return;}// Input value is valid. Synchronize `lastValidValue`lastValidValue = value;}return (<form><inputid="some-numeric-input"type="text"onBeforeInput={handleBeforeInput}onChange={handleChange}/></form>);}
If you're unfamiliar with the
This approach works. But is it really worth it? The code is more verbose; I still have to pollute two
If that's what you're thinking, then you're asking the right questions. :) Thankfully, there is a solution that beautifully resolves this concern.
React Actions
I have been playing around with
I used to think that adding re-usable functionality to HTML elements was only possible in Svelte, but it's actually still possible in React thanks to the React
(Note: If you're unfamiliar with React refs, you should review the ref documentation before continuing.)
// components/MyPage.tsximport React from "react";import actFormatted from "./actions/actFormatted";export default function MyPage() {return (<form><input ref={actFormatted(/^\d*$/)} id="some-numeric-input" type="text" /></form>);}
// actions/actFormatted.tsfunction actFormatted(pattern: RegExp) {/** Stores the react `ref` */let input: HTMLInputElement | null;/** Tracks the last valid value of the input. */let lastValidValue: string;/** Helps ensure that the last valid value is maintained in the input. */function handleBeforeInput(event: Event & { target: HTMLInputElement }): void {lastValidValue = event.target.value;}/** Allows the user to input correctly-formatted values. Blocks invalid inputs. */function handleInput(event: Event & { target: HTMLInputElement }): void {const { value, selectionStart } = event.target;if (!/^\d*$/.test(value)) {event.target.value = lastValidValue;const cursorPlace = (selectionStart as number) - (value.length - event.target.value.length);requestAnimationFrame(() => event.target.setSelectionRange(cursorPlace, cursorPlace));return;}lastValidValue = value;}return function (reactRef: typeof input): void {if (reactRef !== null) {input = reactRef;input.pattern = pattern.toString().slice(1, -1); // Strip the leading and ending forward slashesinput.addEventListener("beforeinput", handleBeforeInput as EventListener);input.addEventListener("input", handleInput as EventListener);} else {input?.removeEventListener("beforeinput", handleBeforeInput as EventListener);input?.removeEventListener("input", handleInput as EventListener);input = null;}};}export default actFormatted;
I call these... React Actions. (Did that give you a strong reaction? π)
To begin, I take advantage of the
Notice that, just as with Svelte Actions, we have to take responsibility for cleaning up the event listeners in our React Actions. According to the React docs:
React will call the
callback with the DOM element when the component mounts, and call it withrefwhen it unmounts. Refs are guaranteed to be up-to-date beforenullorcomponentDidMountfires.componentDidUpdate
Thus, in our function, we're making sure to add the event listeners when the react reference exists (i.e., during mounting), and remove the event listeners when the
Note: You probably noticed that this time I'm adding an
Thankfully, most well-known frontend frameworks like Vue and Svelte respect this difference; unfortunately, React does not. And since our function is using vanilla JS (not React), we have to use the regular
What Are the Benefits to This Approach?
I believe this can be game changing! And for a few reasons, too!
First, it means that we don't run into the issues I mentioned in my first article about using controlled inputs. This means that we can reduce code redundancy, remove unnecessary re-renders, and maintain code and skills that are transferrable between frontend frameworks.
Second, we have a re-usable solution to our formatting problem.
Someone may say, "Couldn't we have added re-usability via components?", the answer is yes. However, in terms of re-usability, I prefer this approach over creating a custom hook or creating a re-usable component. Regarding the former, it just seems odd to use hooks for something so simple. The latter option can get you in trouble if you want more freedom over how your inputs are styled. (In addition, if you aren't using TypeScript, then redeclaring ALL the possible
Third, we unblock our event handlers. What do I mean? Well, unlike Svelte, React doesn't allow you to define multiples of the same event handler on a JSX element. So once you take up a handler, that's it. Sure, you can simulate defining multiple handlers at once by doing something like this:
function MyPage() {function handleChange(event: React.ChangeEvent<HTMLInputElement>) {callback1(event);callback2(event);}return (<form><input onChange={handleChange} /></form>);}
But that approach is rather bothersome β especially when you only want to do something as simple as format an input. By using React Actions, we've freed up that
Fourth, this approach is compatible with state variables! Consider the following:
import React, { useState } from "react";import actFormatted from "./actions/actFormatted";export default function MyPage() {const [value, setValue] = useState("");function handleChange(event: React.ChangeEvent<HTMLInputElement>) {setValue(event.target.value);}return (<form><inputref={actFormatted(/^\d*$/)}id="some-numeric-input"type="text"value={value}onChange={handleChange}/></form>);}
This situation acts almost exactly the same as if we were just controlling a regular input. The difference? Our
"But Mutations!!!"
Since this is a React article, I imagine there are a few people who might complain about how this approach includes mutations (not on state... just on
And the Possibilities Don't Stop with Inputs...
You can create whatever kind of React Action you need to get the job done for your inputs. But you can go even further! For instance, have you ever needed to make HTML Elements behave as if they're buttons? Please don't tell me you're still under the slavery of using "re-usable components":
interface ButtonLikeProps<T extends keyof HTMLElementTagNameMap>extends React.HTMLAttributes<HTMLElementTagNameMap[T]> {as: T;}function ButtonLike<T extends keyof HTMLElementTagNameMap>({children,as,...attrs}: ButtonLikeProps<T>) {const Element = as as any;function handleKeydown(event: React.KeyboardEvent<HTMLElementTagNameMap[T]>): void {if (["Enter", " "].includes(event.key)) {event.preventDefault();event.currentTarget.click();}}return (<Element role="button" tabIndex={0} onKeyDown={handleKeydown} {...attrs}>{children}</Element>);}
Nope. Don't like it. It's a little lame that with the "re-usable component" approach, we're forced to create a prop that represents the type of element to use (whether we default its value or not). Personally, I have also found that making a clean, re-usable, flexible TypeScript interface was difficult to do without running into problems, hence the one disugsting use of
We can make things much simpler with React Actions:
import React from "react";import actFormatted from "./actions/actFormatted";import actBtnLike from "./actions/actBtnLike";export default function MyPage() {return (<form><input ref={actFormatted(/^\d*$/)} id="some-numeric-input" type="text" /><label ref={actBtnLike()} htmlFor="file-upload">Upload File</label><input id="file-upload" type="file" style={{ display: "none" }} /></form>);}
// actions/actBtnLike.ts// Place this on the outside so that we don't have to define it every time an element mountsfunction handleKeydown(event: KeyboardEvent & { currentTarget: HTMLElement }): void {if (event.key === "Enter" || event.key === " ") {event.preventDefault();event.currentTarget.click();}}/** Makes an element focusable and enables it to receive `keydown` events as if it were a `button`. */function actBtnLike() {let element: HTMLElement | null;return function (reactRef: typeof element): void {if (reactRef !== null) {element = reactRef;element.tabIndex = 0;element.addEventListener("keydown", handleKeydown as EventListener);} else {element?.removeEventListener("keydown", handleKeydown as EventListener);element = null;}};}export default actBtnLike;
By adding a JSDoc comment, we can add some IntelliSense to our React Action so that new developers know what this function is doing! And by placing
In my mind, this is only the beginning. I encourage everyone who reads this article to explore the new possibilities for their React applications with this approach!
Don't Forget Your Tests!
Before wrapping up, I just wanted to make sure it was clear that actions are testable too!
// actions/__tests__/actBtnLike.test.tsximport React from "react";import "@testing-library/jest-dom/extend-expect";import { render } from "@testing-library/react";import userEvent from "@testing-library/user-event";import actBtnLike from "../actBtnLike";describe("Act Button-Like Action", () => {it("Causes an element to behave like a button for keyboard events", async () => {const handleClick = jest.fn((event: React.MouseEvent<HTMLLabelElement, MouseEvent>) => {console.log("In a real app, the file navigator would be opened.");});const { getByText } = render(<form><label ref={actBtnLike()} htmlFor="file-upload" onClick={handleClick}>Upload File</label><input id="file-upload" type="file" style={{ display: "none" }} /></form>);// Shift focus to label element, and activate it with keyboard actionsconst label = getByText(/upload file/i);await userEvent.tab(); // Proves the element is focusableexpect(label).toHaveFocus();await userEvent.keyboard("{Enter}");expect(handleClick).toHaveBeenCalledTimes(1);await userEvent.keyboard(" ");expect(handleClick).toHaveBeenCalledTimes(2);});});
// actions/__tests__/actFormatted.test.tsximport React from "react";import "@testing-library/jest-dom/extend-expect";import { render } from "@testing-library/react";import userEvent from "@testing-library/user-event";import actFormatted from "../actFormatted";describe("Act Formatted Action", () => {it("Enforces the specified pattern for an `input`", async () => {const numbersOnlyRegex = /^\d*$/;const { getByLabelText } = render(<form><label htmlFor="number-input">Numeric Input</label><input id="number-input" ref={actFormatted(numbersOnlyRegex)} /></form>);const numericInput = getByLabelText(/numeric input/i) as HTMLInputElement;await userEvent.type(numericInput, "abc1def2ghi3");expect(numericInput).toHaveValue("123");});});
(Please note that the latest beta version of
Pretty straightforward. Note that, as is the case for all kinds of testing, your testing capabilities are limited to your testing tools. For instance, as noted above, version 14 of User Event Testing Library is needed to support tests for anything that relies on
In Conclusion
And that's a wrap! Hope this was helpful! Let me know with a clap or a shoutout on Twitter maybe? π
I want to give a HUUUUUUUUUUGE thanks to Svelte! That is, to everyone who works so hard on that project! It really is a great framework worth checking out if you haven't done so already. I definitely would not have discovered this technique if it wasn't for them. And I want to give a special second shoutout to @kevmodrome again for the help I mentioned earlier.
I want to extend another enormous thanks to @willwill96! He caught an implementation bug in the earlier version of this article. π¬
Finally, I want to extend another big thanks to my current employer (at the time of this writing), MojoTech. ("Hi Mom!") Something unique about MojoTech is that they give their engineers time each week to explore new things in the software world and expand their skills. Typically, I learn most and fastest on side projects (when it comes to software, at least). If it wasn't for them, I probably wouldn't have been able to explore Svelte, which means I wouldn't have fallen in love with the framework and learned about actions, which means this article never would have existed. π©