In Part 1, I wrote about how to build forms with React JSON Schema Form and integrate them into a Redux and Typescript project. In this Part 2 blog post, I take a deep dive into how I set up the project to handle building forms using these technologies. The repo that demonstrates this code in action is here: https://github.com/bkinseyx/cra-redux-ts/tree/react-jsonschema-form (don’t forget to use the react-jsonschema-form branch).
Code you need to set up only once
Beyond the code described in Part 1, here is some helper code you need to set up only once that can be used across your app on every React JSON Schema Form you set up.
We need reusable components:
$ tree components
components/
|-- FormAlertBar.tsx
|-- FormButtonBar.tsx
`-- PhoneNumberWidget.tsx
And I would suggest some form helper functions.
$ tree utils
utils
`-- form.ts
Form Alert Bar
The Form Alert Bar component is meant to be placed just above the form (although in some solutions it might be a pop up of some sort). This displays any messages that come from the server: success and failure. We also let the user know when the form is submitting the data to the server via the API.
In Typescript, it is best practice to type the props that are passed into your component. In this case, we have a sliceKey, this is simply the name of the Redux store slice that we are using as defined by the code in rootReducer.ts.
The tricky part of syntax that I want to draw your attention to is (state as any). The issue here is that Typescript is forcing me to type state this way because it can’t understand which state we are talking about: this component is designed to work across different slices. Normally (for one-off components) we know the slice we are working with, and then we don’t have to type the state as “any”, and we can take full advantage of typing features including autocompletion. But in this case, we need to just know this naming convention will work across all slices that use this component, specifically that we have values in our store slice as follows: serverSuccessMessage, serverError, submitting.
FormAlertBar.tsx
interface FormAlertBarProps {
sliceKey: string;
}
const FormAlertBar: React.FC<FormAlertBarProps> = ({ sliceKey }) => {
const { serverSuccessMessage, serverError, submitting } = useSelector(
(state: RootState) => (state as any)[sliceKey]
);
return (
<React.Fragment>
{serverSuccessMessage && (
<div className="alert alert-success" role="alert">
{serverSuccessMessage}
</div>
)}
{serverError && (
<div className="alert alert-danger" role="alert">
{serverError}
</div>
)}
{submitting && (
<div className="alert alert-info" role="alert">
Submitting...
</div>
)}
</React.Fragment>
);
};
export default FormAlertBar;
Form Button Bar
The Form Button Bar component adds one more prop: clearForm.
FormButtonBar.tsx
interface FormButtonBarProps {
sliceKey: string;
clearForm: ActionCreatorWithoutPayload;
}
const FormButtonBar: React.FC<FormButtonBarProps> = ({
sliceKey,
clearForm
}) => {
const dispatch = useDispatch();
const { submitting } = useSelector(
(state: RootState) => (state as any)[sliceKey]
);
return (
<div>
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
>
Submit
</button>
<button
className="btn btn-secondary"
type="reset"
onClick={() => dispatch(clearForm())}
>
Clear
</button>
</div>
);
};
export default FormButtonBar;
clearForm is the action that is dispatched when the clear form button is pressed. In this case, there is no payload, so we type it as ActionCreatorWithoutPayload. You might be wondering how I knew that. I looked it up using the power of Typescript.
Recall that clearForm is defined in the reducer as follows:
demoFormSlice.ts
/** call to clear form (e.g. from a Clear button) */
clearForm(state) {
state.formKey = Date.now(); // clears the form!
state.serverSuccessMessage = null;
state.serverError = null;
state.submitting = false;
state.formData = null;
},
Note that for this reducer, there is no payload. We don’t use it anyway, so there is no need to list action (where the payload comes from) as a parameter. But the action parameter is still passed by default, and so we get the default type. In Visual Studio Code, if I mouse over where clearForm is exported, I get a clear idea of the type. Now, I happen to know we will never need a payload, therefore I can omit the <string>. I’m not sure why it defaults to having <string> there (no type definitions of any project are perfect) but I know I don’t need it. These are edge cases that will stretch your understanding of Typescript as you come to them.
demoFormSlice.ts
Phone Number Widget
I wanted to provide an example of a custom component that extends React JSON Form. Note that the props are passed in by React JSON Schema Form itself. I did want to point out I’m using a masking library called react-input-mask. In general, I recommend input masking when appropriate. I think a standard US/Canada phone number is a perfect use case.
Note that we do not connect the widget directly to the Redux store. That connection is made by the form’s formData which includes the data of every field.
PhoneNumberWidget.tsx
import InputMask from "react-input-mask";
interface PhoneNumberWidgetProps {
value: string;
required: boolean | undefined;
onChange: (value: string) => void;
}
/**
* This widget is an custom field component that can be used inside of
* react-jsonschema-forms.
* The props are fed in from react-jsonschema-forms.
* It is necessary to make sure value is initialized as a string,
* rather than undefined.
* Because otherwise react will give a warning about an
* uncontrolled component changing
* to a controlled component. I also like input masking.
*/
const PhoneNumberWidget: React.FC<PhoneNumberWidgetProps> = ({
value = "",
required,
onChange
}) => (
<InputMask
mask="999-999-9999"
maskChar="_"
type="tel"
className="form-control"
value={value}
required={required}
onChange={event => onChange(event.target.value)}
/>
);
export default PhoneNumberWidget;
Form Helper Functions
A lot of what follows is my own custom code, I hope you find it useful or interesting.
The key bit of functionality I feel that React JSON Schema Form is lacking is the ability to define format regExp expressions co-located with the field error messages that appear should there be a format validation error. So this is my magic bit of code to make it easy.
form.ts
interface Formats {
[key: string]: {
regex: RegExp;
error: string;
};
}
/**
* I think it would be best to define formats directly with the
* error message.
* This isn't built into react-jsonschema-format, so I will do it myself.
*/
const formats: Formats = {
phoneNumberFormat: {
regex: /^$|^\(?([2-9][0-8][0-9])\)?[-. ]?([2-9][0-9]{2})[-. ]?([0-9]{4})$/,
error: "must be valid and in the format of XXX-XXX-XXXX"
},
emailAddressFormat: {
regex: /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
error: "must be in the format of [email protected]"
}
};
A lot of the rest of the code in this file is just munging to get the formats and error messages in a format that React JSON Schema Form can understand.
I wanted to point out this is where we define what custom widgets are available to React JSON Schema Form.
/** extended components used by react-jsonschema-forms */
const widgets = {
phoneNumberWidget: PhoneNumberWidget as StatelessComponent<WidgetProps>
};
You will note I had to cast PhoneNumberWidget as StatelessComponent<WidgetProps>. You might be wondering how I knew that. This is the magic of Typescript again.
In Visual Studio Code, I mouse over the widgets prop in DemoForm.tsk:
And so there it is, I have to change the type to StatelessComponent<WidgetProps>. I think this is a flaw with the type definition of React JSON Schema form. Typescript is a double edge sword. Sometimes it forces you to understand on a deeper level what’s really going on and fix issues and edge cases with type definitions. If I really wanted to push farther I could attempt a PR on the type definitions @types/react-jsonschema-form project.
How to Troubleshoot a Typing Issue with Confidence
I ran across this real-world typing issue and the solution was not immediately obvious to me. So I wanted to go through the process of how I tackled this error.
If you hover over the title, or place your cursor on top of it and hit Ctrl-k Ctrl-i, you get hover information as follows:
What this means is that JSONSchema6Definition type might be false. But in our form schema json code, we know the title will always be set. One way to fix this is to use the “any” keyword. That fixes the issue with .title, but we still have another squiggly to deal with.
If we hover over properties, or place our cursor on properties and use the keyboard shortcut Ctrl-k Ctrl-i, then we get the following:
This is another case where the existing type is looser than we expect, via the default type, the “properties” property of the schema might be undefined. But in our code, we know it is always defined. So again, we are safe to add the “any” keyword here.
return ((schema.properties as any)[propertyFromError] as any).title;
So, I happen to know that if we use “any” on the left like that we no longer need “the” any on the right so we can simplify, and here is the final code:
/** field title is the plain english name (with space chars). */
const getFieldTitleFromError = (error: AjvError, schema: JSONSchema6) => {
// for some reason error properties are prepended with a . so we
// strip 'em off
const propertyFromError = error.property.substr(1);
// "as any" necessary here because JSONSchema6 properties can be
// undefined for some reason that eludes me.
// In our case we are fine here since we know all our properties must
// be strings.
return (schema.properties as any)[propertyFromError].title;
};
Sometimes “any” is necessary to get your code working. Ideally, we would want to fix the problem as far upstream as possible and perhaps build our own type that extends JSONSchema6 but for the sake of expediency, I think it’s often okay with to use “any” in order to move forward. Don’t be too hard on yourself, and solve the most important problems first. Congratulations on using Typescript at all, and as you get more and more comfortable with it, little annoying problems become opportunities for stretching yourself and learning just a little bit more.
Summary
I hope you enjoyed this deep dive into how to get React JSON Schema Form to integrate with a Redux and Typescript project. While these technologies can be intimidating to use together at first, the more you familiarize yourself with them the more they pack a powerful punch in terms of productivity and dev ergonomics.
If you have any questions at all, please engage with us via comments on this blog post, or reach out to us at https://www.xtivia.com/contact/.