In a previous blog, I wrote about combining React, Redux, Typescript, and other technologies in order to build a web app following best practices, and I provided a very simple example app. In this follow-up blog post, I will flesh out the example by adding a form using “React JSON Schema Form” and integrating it with Redux and Typescript.
I have a repo that demonstrates the solution I came up with: https://github.com/bkinseyx/cra-redux-ts/tree/react-jsonschema-form
(Don’t forget to use the react-jsonschema-form branch.)
React JSON Schema Form
React JSON Schema form is a technology for building forms with React. It takes the approach of defining the form itself separate from the UI (user interface) of the form. This approach reminds me of the separation of HTML and CSS. The specification for both the form schema and the UI schema is cross-language JSON Schema. In this case, we just define the JSON schema with simple static .json files. But you could imagine more sophisticated possibilities, for example, where the json is actually generated on the server.
React JSON Schema Form helps with form validation, and it is extensible with custom form widgets, and you will see one of these custom widgets in Part 2. React JSON Schema uses Bootstrap semantics by default, which makes it a good fit with a Liferay DXP solution among others.
React JSON Schema Form does not directly interface with Redux. Instead, we have to wire up the connections ourselves. This is the heart of it:
const dispatch = useDispatch();
const { formKey, formData } = useSelector(
(state: RootState) => state.demoForm
);
return (
<Form
key={formKey}
schema={schema}
uiSchema={uiSchema}
widgets={widgets}
customFormats={getCustomFormats(formats)}
transformErrors={getTransformErrors(schema, formats)}
formData={formData}
onChange={({ formData }) => dispatch(formDataChange(formData))}
onSubmit={({ formData }) => dispatch(submitDemoForm(formData))}
>
If you look carefully, you will see formData is being passed to the formDataChange action and the submitDemoForm action. As we will see, these update the formData in the Redux store. Additionally, formData comes from the Redux store via the useSelector hook, and is passed as a prop to the React JSON Schema Form. What this means is that there is a tight two-way binding between the formData and the form. If one changes, so does the other. You can think of the React JSONSchema Form as a controlled component.
In this example, we’re going to make a form collect the kind of information you might expect from a contact us form.
Form Schema
The form schema defines the form itself. Each field is defined by a “property.” The title is the plain English name of the field. Types are just strings in this example, but we do have some custom formats. A format is a regex that we can use to validate the field value. You can imagine a format (such as email address format) is not something we just want to define for a single form because we might want to re-use it across many forms. We don’t want to define these formats for each form that we implement; instead, we can define them once in the app and re-use them in individual forms as we need them. We will see in Part 2 how we define our formats so they can be used by multiple forms.
demoFormSchema.json
{
"type": "object",
"required": ["firstName", "lastName", "email"],
"properties": {
"firstName": { "type": "string", "title": "First Name" },
"lastName": { "type": "string", "title": "Last Name" },
"email": {
"type": "string",
"title": "Email",
"format": "emailAddressFormat"
},
"phoneNumber": {
"type": "string",
"title": "Phone Number",
"format": "phoneNumberFormat"
},
"comment": { "type": "string", "title": "Comment" }
}
}
Form UISchema
The UISchema defines how a form field will be implemented, and any special implementation details. Here we autofocus the first name field. We define widgets for the phone number and comment fields. In this case, textarea is a built-in widget (an HTML textarea), but phoneNumberWidget is a special custom widget. Like formats, we want to define our custom widgets in a way that they can be defined once in our app and used in multiple forms as needed. We will see later how to do this.
demoFormUiSchema.json
{
"firstName": {
"ui:autofocus": true
},
"phoneNumber": {
"ui:widget": "phoneNumberWidget"
},
"comment": {
"ui:widget": "textarea"
}
}
Form Demo Slice
Our reducer is defined as follows:
demoFormSlice.ts
const demoFormSlice = createSlice({
name: "demoForm",
initialState,
reducers: {
/** call when form data changes */
formDataChange(state, action: PayloadAction<any>) {
state.formData = action.payload;
state.serverSuccessMessage = null;
state.serverError = null;
},
/** 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;
},
/** api call starts -- not called directly, see submitDemoForm */
submitDemoFormStart(state) {
state.serverSuccessMessage = null;
state.serverError = null;
state.submitting = true;
},
/** api call successful */
submitDemoFormSuccess(state, action: PayloadAction<string | null>) {
state.serverError = null;
state.submitting = false;
state.serverSuccessMessage = action.payload;
},
/** api call failed */
submitDemoFormFailure(state, action: PayloadAction<string | null>) {
state.serverError = action.payload;
state.serverSuccessMessage = null;
state.submitting = false;
}
}
});
If you are observant, you might have noticed something is missing: the submitDemoForm action. In this case, submitDemoForm does not change the state directly, it calls other actions which do the Redux work. Therefore it is defined out of the reducer, but still in the slice file, like this:
/**
* Call this instead of a redux action directly in order to submit the
* form.
* This function will call the redux actions as needed.
* We define this outside of the reducer because it doesn't directly
* modify state.
* Instead it will asynchronously call reducers as needed.
* A thunk is a function returned by another function.
* It is a style of doing async stuff with redux.
* redux-toolkit inserts redux-thunk middleware by default.
*/
export const submitDemoForm =
(formData: any): AppThunk => async dispatch => {
// This async await pattern replaces calling .then and .catch on
// promises.
// It is supposed to be more clear, and less nested.
try {
dispatch(submitDemoFormStart());
const successMessage = await submitDemoFormApi(formData);
dispatch(submitDemoFormSuccess(successMessage));
} catch (err) {
dispatch(submitDemoFormFailure(err.toString()));
}
};
The Mock API
In our silly example, our mock API doesn’t really make calls to a server. Instead, we delay by a 1000ms and randomly decide if the mocked request was successful. Note how we type the promise, so we know it always returns a string.
In this example, we type the formData as any. In general, for Typescript projects, I try to avoid “any,” but in this case, I think it makes sense. In a React JSON Schema Forms project, the single source of truth of the type of formData should be the form schema json itself. It would be cool to somehow automatically extract the type of that schema JSON during build or compile time and apply it here, and I have seen some type solutions that go this far in the GraphQL universe, but this is way beyond the scope of this blog post.
api.ts
/** Mock an api request to the server. Returns a Promise<string>.
* Because we are just mocking, we don't actually use the formData.
*/
export const submitDemoFormApi = (formData: any) =>
new Promise<string>((resolve, reject) => {
setTimeout(
() =>
Math.random() < 0.5 // 50-50 chance of success
? resolve("The form was submitted successfully.")
: reject(
"The server returned an unspecified error. Please try again."
),
1000
);
});
Code you need for each form
I recommend each form you build to be in its own slice. The big advantage is that you can reuse code that expects certain naming conventions. In Part 2, I’ll give some specific examples of reusable components that take advantage of consistent naming conventions.
What you need to build each form:
- The form component itself (tsx)
- The form Schema (json)
- The form UISchema (json)
- The redux slice (ts)
In our DemoForm example, all of these get placed in our feature directory like so:
$ tree .
`-- features
`-- demoForm
|-- DemoForm.tsx
|-- demoFormSchema.json
|-- demoFormUiSchema.json
`-- demoFormSlice.ts
Summary
I hope you enjoyed this tutorial of how to get React JSON Schema Form to integrate with a Redux and Typescript project. In Part 2, I will show how I set up the helper code that helps make using the form easy like this.
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 here.