Building Forms with rx.js
Leo Chan
Posted on July 28, 2021
Introduction
When building a form page, typically you'll want the following.
- perform some action when the "SUBMIT" button is clicked, e.g. calling an API, uploading a file etc.
- perform some action when submit is successful, e.g. showing a success message, redirecting to another page etc.
- display some error when submit failed
- while submit is processing, show a loading sign or disable the "SUBMIT" button to prevent duplicated submits
- occassionally you might also want to pre-populate the form fields, maybe with data fetched from backend
That's quite a lot of logic. You may jam all that into the react component, but I find it confusing to read and difficult to test. I'd like to demostrate another way that I find more maintainable.
Guiding Principles
We want to adhere to the single responsibility principle, so let's separate the presentation logic from the business logic. Presentation logic deals with drawing the page. And the business logic deals with what happens behind the page (e.g. calling the API, signalling success/failure, input validation etc.)
We also want to keep the code DRY. It should be easy to apply code reuse techniques such as inheritance and compositions.
Code Example
Let's build a form to create a post. A post has two data points, a title and a body.
We'll use https://jsonplaceholder.typicode.com. It provides a dummy REST API to create/update/retrieve a post object
- GET /posts/1 retrieves a post with ID=1
- POST /posts creates a post.
- PUT /posts/1 updates a post with ID=1
Presentation Logic
Nothing fancy here. Just a basic form input page. I'm also using react-hook-form to manage the data-binding between the form fields and the state variable. I'm using React.js here but you can use other frontend libraries. React.js is only used for the presentation logic but not the business logic.
export default function CreateForm() {
// Using react-hook-form to handle data-binding with form fields.
// With it you can prepopulate form fields, set error messages per field etc.
const form = useForm()
// state variables to store error messages and a loading flag
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
// rendering the page
return (
<React.Fragment>
<h4>Create Post</h4>
<form onSubmit={form.handleSubmit(onCreate)}>
<div>
<label>Title</label>
<input type='text' {...form.register("title")}></input>
</div>
<div>
<label>Body</label>
<textarea {...form.register("body")}></textarea>
</div>
{error ? <p>{error}</p> : null}
<input
type='submit'
disabled={loading}
value={loading ? 'Please Wait ...' : 'Submit'}>
</input>
</form>
</React.Fragment>
)
}
Business Logic
I find it helpful to think of frontend logic in terms of event streams. The event stream paradigm is applicable to many aspects in frontend development. For example ....
Fetching and displaying data on page ...
And sometimes you may want to mix and match ...
How do we compose complex event-driven logic in a managable way? If we simply use promises and callbacks, we'll easily end up with a messy callback hell
I find it helpful to use the rx.js library and the BLOC pattern (short for Business LOgic Component). rx.js is a tool to compose complex event streams, a.k.a reactive programming. A BLOC is a class that only accept streams as input, handles all the reactive logic, and compose the output streams. Whenever there's a DOM event (e.g. page load, button clicked, form submitted), the react component will sink an event into the BLOC. BLOC will be responsible for computing when to trigger the output streams. (e.g. when form submit completes or errors out, when form submit is in progress etc.) The react component then subscribes to the BLOC output streams, and re-renders the page when the BLOC tells it to do so.
So we have a clear separation of logic
The presentation layer handles the rendering of the page, hooking up the DOM events with the BLOC inputs, and re-rendering when triggered by BLOC outputs.
The BLOC layer handles all the reactive logic and API requests.
Using BLOC pattern and rx.js in our code example ...
/*
* CreateFormBloc.js (BLOC layer)
*/
import { Subject, mapTo, merge, of, startWith, switchMap, share, filter } from "rxjs";
import { fromFetch } from 'rxjs/fetch';
const BASE_URL = 'https://jsonplaceholder.typicode.com'
export default class CreateFormBloc {
constructor() {
this.formData = new Subject(); // Subjects are used to accept inputs to the BLOC
this.createPost = this.formData.pipe(
switchMap(data => fromFetch(`${BASE_URL}/posts`, { method: 'POST', body: JSON.stringify(data) })),
switchMap(resp => {
if (resp.ok) {
return resp.json()
} else {
return of(new Error('Error Occurred when creating post'))
}
}),
share() // share() operator prevents the API from triggering multiple times on each downward streams.
)
// the SUCCESS output stream. React.js can subscribe to this and render a success message.
this.createPostSuccess = this.createPost.pipe(
filter(resp => !(resp instanceof Error))
)
// the FAILED output stream. React.js can subscribe to this and render an error message.
this.createPostFailed = this.createPost.pipe(
filter(resp => resp instanceof Error)
)
// Emits a boolean flag indicating whether submission is in progress or not.
this.createPostInProgress = merge(
this.formData.pipe(mapTo(true)),
this.createPost.pipe(mapTo(false)),
).pipe(
startWith(false),
)
}
}
/*
* CreateForm.js (Presentation Layer)
*/
export default function CreateForm() {
const [bloc] = React.useState(new CreateFormBloc())
const form = useForm()
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
/*
* subscribing to BLOC output streams, triggering the page to re-render.
*/
const sub = new Subscription()
sub.add(bloc.createPostSuccess.subscribe(_ => alert('Post Created Successfully!')))
sub.add(bloc.createPostFailed.subscribe(err => setError(err.message)))
sub.add(bloc.createPostInProgress.subscribe(setLoading))
return () => sub.unsubscribe() // unsubscribe the event handlers when component is destroyed.
}, [])
// when form submits, we input the form data into the BLOC
function onCreate(data) {
bloc.formData.next(data)
}
return (
<form onSubmit={form.handleSubmit(onCreate)}>
// .... rendering logic
)
}
The Edit Page
We have built the Create page. Now let's build the Edit page with rx.js and BLOC pattern
There's more to do in the Edit page because we want to pre-populate the form fields with existing data.
- When the page loads, we get the ID of the Post object from URL parameter
- We fetch the data of the Post object from API, and prepopulate the form fields
- When the form is submitted, we call the API with submitted data to update the Post object
- We display a success message when the API call is successful, otherwise we display an error message.
/*
* EditFormBloc.js (BLOC layer)
*/
import { of, Subject, switchMap, withLatestFrom, share, filter, merge, mapTo, startWith } from "rxjs";
import { fromFetch } from 'rxjs/fetch';
const BASE_URL = 'https://jsonplaceholder.typicode.com'
export default class EditFormBloc {
constructor() {
this.formData = new Subject()
// Subject to input the ID of the Post object being edited
this.postID = new Subject()
// When postID is inputted, BLOC will fetch the Post object.
// React.js can use this to pre-populate the form fields.
this.initialFormData = this.postID.pipe(
switchMap(postID => fromFetch(`${BASE_URL}/posts/${postID}`)),
switchMap(resp => resp.json()),
)
// updating the Post object when form is submitted
this.updatePost = this.formData.pipe(
withLatestFrom(this.postID),
switchMap(([data, postID]) => {
const url = `${BASE_URL}/posts/${postID}`
const payload = { method: 'PUT', body: JSON.stringify(data) }
return fromFetch(url, payload)
}),
switchMap(resp => {
if (resp.ok) {
return resp.json()
} else {
return of(new Error('Error updating Post'))
}
}),
share(),
)
// BLOC output. React.js will subscribe and display a success message.
this.updatePostSuccess = this.updatePost.pipe(
filter(resp => !(resp instanceof Error))
)
// BLOC output. React.js will subscribe and display an error message.
this.updatePostFailed = this.updatePost.pipe(
filter(resp => resp instanceof Error)
)
// BLOC output. React.js will subscribe and disable the submit button accordingly.
this.updatePostInProgress = merge(
this.formData.pipe(mapTo(true)),
this.updatePost.pipe(mapTo(false)),
).pipe(
startWith(false),
)
}
}
/*
* EditForm.js (Presentation Layer)
*/
import React from 'react'
import { useForm } from 'react-hook-form'
import { Subscription } from 'rxjs'
import EditFormBloc from './EditFormBloc'
import { useRouteMatch } from 'react-router-dom'
export default function EditForm() {
const form = useForm()
const match = useRouteMatch()
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [bloc] = React.useState(new EditFormBloc())
React.useEffect(() => {
const sub = new Subscription()
/*
* Subscribe to BLOC output streams.
* So we can display when submission is successful/failed/in progress
* We also subscribe to the initialFormData stream, and pre-populate the form fields.
*/
sub.add(bloc.updatePostSuccess.subscribe(_ => alert('Post Updated Successfully!')))
sub.add(bloc.updatePostFailed.subscribe(err => setError(err.message)))
sub.add(bloc.updatePostInProgress.subscribe(setLoading))
sub.add(bloc.initialFormData.subscribe(data => {
form.setValue('title', data.title, { shouldValidate: true, shouldDirty: false })
form.setValue('body', data.body, { shouldValidate: true, shouldDirty: false })
}))
return () => sub.unsubscribe() // unsubscribe the event handlers when component is destroyed.
}, [])
React.useEffect(() => {
// When the page loads, we get the Post ID from URL parameter and input into the BLOC
bloc.postID.next(match.params.post_id)
}, [])
// When form submits, we input formData into the BLOC to trigger API call.
function onUpdate(data) {
bloc.formData.next(data)
}
return (
<form onSubmit={form.handleSubmit(onUpdate)}>
// ... rendering logic
)
}
Code Reuse
Presentation Layer
The form component looks the same in the Create page and the Edit page. We can reuse with a shared PostForm component.
/*
* PostForm.js
*/
import React from 'react'
export default function PostForm(props) {
const { form, error, loading, onSubmit } = props
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div>
<label>Title</label>
<input type='text' {...form.register("title")}></input>
</div>
<div>
<label>Body</label>
<textarea {...form.register("body")}></textarea>
</div>
{error ? <p>{error}</p> : null}
<input
type='submit'
disabled={loading}
value={loading ? 'Please Wait ...' : 'Submit'}>
</input>
</form>
)
}
/*
* CreateForm.js
*/
export default function CreateForm() {
const form = useForm()
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
// ...
return (
<React.Fragment>
<h4>Create Post</h4>
<PostForm
form={form}
error={error}
loading={loading}
onSubmit={onCreate}>
</PostForm>
</React.Fragment>
)
}
/*
* EditForm.js
*/
export default function EditForm() {
const form = useForm()
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
// ...
return (
<React.Fragment>
<h4>Edit Post</h4>
<PostForm
form={form}
error={error}
loading={loading}
onSubmit={onUpdate}>
</PostForm>
</React.Fragment>
)
}
BLOC Layer
In addition to the Create page and the Edit page, we probably also need a View page.
- When the page loads, we get the ID of the Post object from the URL
- We fetch the data of this Post object from API, and display the Post object on the page.
This is the same in the Edit Page, we also need to fetch data to pre-populate the form fields. Since our BLOC is a javascript class, we can apply code reuse techniques likes inheritances and compositions. There are many ways to do this, I like to use mixins to do compositions with mixwith.js
Let's put all the common functionalities to fetch a Post object in a Mixin. Instead of defining the subjects and streams in the constructor like we did before, we will define them with lazy-loaded getter functions. This enables us to override/extend each function in subclasses if necessary.
/*
* FetchPostMixin.js
*/
import { Mixin } from 'mixwith'
import { has } from "lodash";
import { of, Subject, switchMap } from "rxjs";
import { fromFetch } from 'rxjs/fetch';
const BASE_URL = 'https://jsonplaceholder.typicode.com'
let FetchPostMixin = Mixin((superclass) => class extends superclass {
get postID() {
if (!has(this, '_postID')) {
this._postID = new Subject()
}
return this._postID
}
get post() {
if (!has(this, '_post')) {
this._post = this.postID.pipe(
switchMap(postID => fromFetch(`${BASE_URL}/posts/${postID}`)),
switchMap(resp => {
if (resp.ok) {
return resp.json()
} else {
return of(new Error('Error fetching Post'))
}
}),
)
}
return this._post
}
});
export default FetchPostMixin
Now we can reuse this Mixin in the View page
/*
* ViewPageBloc.js (BLOC layer)
*/
import { mix } from "mixwith";
import FetchPostMixin from "blocs/FetchPostMixin";
export default class ViewPostBloc extends mix(Object).with(FetchPostMixin) { }
/*
* ViewPage.js (Presentation layer)
*/
import React from 'react'
import { useRouteMatch } from 'react-router-dom'
import { Subscription } from 'rxjs'
import ViewPostBloc from 'blocs/ViewPostBloc'
export default function ViewPost() {
const match = useRouteMatch()
const [bloc] = React.useState(new ViewPostBloc())
const [post, setPost] = React.useState()
React.useEffect(() => {
const sub = new Subscription()
sub.add(bloc.post.subscribe(setPost))
return () => sub.unsubscribe()
}, [])
React.useEffect(() => {
bloc.postID.next(match.params.post_id)
}, [])
return (
<React.Fragment>
<h4>View Post</h4>
{post ? (
<dl>
<dt>Title</dt>
<dd>{ post.title }</dd>
<dt>Body</dt>
<dd>{ post.body }</dd>
</dl>
) : (
<p>Please Wait ...</p>
)}
</React.Fragment>
)
}
And we can reuse this Mixin in the Edit page
/*
* EditFormBloc.js
*/
import { mix } from "mixwith";
import FetchPostMixin from "blocs/FetchPostMixin";
const BASE_URL = 'https://jsonplaceholder.typicode.com'
export default class EditFormBloc extends mix(Object).with(FetchPostMixin) {
get formData() {
// ...
}
get updatePost() {
// ...
}
get updatePostSuccess() {
// ...
}
get updatePostFailed() {
// ...
}
get updatePostInProgress() {
// ...
}
}
/*
* EditForm.js
*/
import React from 'react'
import { useForm } from 'react-hook-form'
import { Subscription } from 'rxjs'
import PostForm from 'components/PostForm'
import EditFormBloc from 'blocs/EditFormBloc'
import { useRouteMatch } from 'react-router-dom'
export default function EditForm() {
const form = useForm()
const match = useRouteMatch()
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [bloc] = React.useState(new EditFormBloc())
React.useEffect(() => {
const sub = new Subscription()
sub.add(bloc.updatePostSuccess.subscribe(_ => alert('Post Updated Successfully!')))
sub.add(bloc.updatePostFailed.subscribe(err => setError(err.message)))
sub.add(bloc.updatePostInProgress.subscribe(setLoading))
sub.add(bloc.post.subscribe(post => {
form.setValue('title', post.title, { shouldValidate: true, shouldDirty: false })
form.setValue('body', post.body, { shouldValidate: true, shouldDirty: false })
}))
return () => sub.unsubscribe()
}, [])
React.useEffect(() => {
bloc.postID.next(match.params.post_id)
}, [])
function onUpdate(data) {
bloc.formData.next(data)
}
return (
// ... rendering logic
)
}
Conclusion
Thanks for reading! I hope this blog post is useful to you. The completed code is available on github
Posted on July 28, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024
November 27, 2024