September 15, 2023 - Real-time Collaborative Editing Form

Times have changed in the world of modern applications. With the rising popularity of platforms like Google Docs, Figma, Miro, and others, users increasingly expect real-time, multi-user experiences. The moment one user makes a change in the interactive interface, others should be able to see that change immediately, without needing to refresh the page. I strongly believe that real-time interfaces provide an excellent user experience, especially in applications where multiple stakeholders are interacting with the same information. They enable better collaboration on specific problems, enhancing teamwork. At my company, Civago, one of our initial missions was to improve collaboration among construction company stakeholders by breaking down silos and increasing transparency. One of our early challenges was providing a seamless real-time experience on our platform.

I recall a time when developing such interfaces posed significant challenges, particularly on the backend. Nowadays, however, there are numerous tools available to track real-time database changes—Firebase and Supabase, for example. We're even experimenting with a newer solution called PowerSync, which offers not just real-time updates but also easily-managed offline-first support.

Front-end development brings its own set of challenges when adopting a real-time approach, requiring us to rethink some standard practices. Take forms, for instance. Forms are ubiquitous in web development, and one of my favorite libraries for handling them is react-hook-form. Typically, forms maintain a local state, allowing users to input information quickly without waiting for database updates. However, this becomes problematic in a collaborative setting, where it's essential to also monitor any changes made by other users in the database. Balancing local state management for responsiveness with real-time database updates for collaborative features is tricky. That's why I've created a custom hook useCollabForm that wraps around react-hook-form's useForm hook to manage the collaborative update behavior.

To implement this functionality, the only requirement is that the entity being edited in the form should have both an 'id' and an 'updatedAt' field in the object returned from the backend.

The useCollabForm hook

The useCollabForm hook is designed for real-time collaborative editing of a form in React. It sets initial form states, keeps track of the last time the form data was updated locally and on the server, and identifies the entity currently being edited. The hook debounces updates to the server to limit API calls. When an entity is updated, either by the user or from the server, the hook intelligently decides whether to reset the form or to continue with the current data. Finally, it returns form control methods for use in the component that utilizes this hook. It uses TypeScript generics to work flexibly with different types of entities that have an id and updatedAt property.

When an entity is updated, the hook checks a few conditions to decide what action to take:

Is it a Different Entity?: The hook first checks if the current entity being edited has a different ID from the previous one. If so, it resets the form to default values.

Server Data vs. Local Data: The hook compares the timestamp of the last server update (updatedAt from the entity) to both the last local update and the last server update it's aware of. If the server's data is newer and the local data is older, it will reset the form to the default values.

This decision-making process ensures that users are always working with the most up-to-date information while minimizing the risk of overwriting each other's changes. It strikes a balance between updating the form with new data from the server and keeping the user's local changes.

Here is the code of the implementation:

import { useState, useEffect } from 'react'

import { DeepPartial, useForm } from 'react-hook-form'
import { useDebouncedCallback } from 'use-debounce'

interface Props<T> {
  entity: T | null
  defaultValues: DeepPartial<T>
  updateEntity: (id: string, data: T) => void
}

/**
 * useCollabForm is a custom React hook designed to integrate react-hook-form
 * with real-time collaborative editing functionality.
 *
 * The hook is particularly useful when multiple users are working on the same
 * interactive form at the same time and the state of the form is stored in a
 * real-time database.
 *
 * @param {Object} props
 * @param {Object} props.entity - The current entity being edited.
 * @param {Function} props.defaultValues - Function to generate default form values.
 * @param {Function} props.updateEntity - Function to update the entity in the database.
 *
 * @returns {Object} - useForm results including methods like control, handleSubmit, etc.
 */
export const useCollabForm = <T extends { id: string; updatedAt: string }>({
  entity,
  defaultValues,
  updateEntity,
}: Props<T>) => {
  // Initialize useForm with default values and onChange mode
  const useFormResult = useForm({
    defaultValues,
    mode: 'onChange',
  })

  const { handleSubmit, reset, watch } = useFormResult

  // State to keep track of the last update timestamps for server and local data
  const [lastServerUpdatedAt, setLastServerUpdatedAt] = useState(new Date())
  const [lastLocalUpdatedAt, setLastLocalUpdatedAt] = useState(new Date())

  // State to store the current entity ID being edited
  const [currentEntityId, setCurrentEntityId] = useState(null)

  // Debounce the updateEntity function to limit the number of API calls
  const debouncedUpdateEntity = useDebouncedCallback(updateEntity, 500)

  useEffect(() => {
    if (!entity || !entity.updatedAt) return

    const serverUpdatedAt = new Date(entity.updatedAt)

    // Flags to identify whether the entity is different or has newer/older data
    const isDifferentEntity = currentEntityId !== entity.id
    const serverDataIsNewer = lastServerUpdatedAt < serverUpdatedAt
    const localDataIsOlder = lastLocalUpdatedAt < serverUpdatedAt

    // If we have a new entity or the server data is newer, reset the form and update states
    if (isDifferentEntity || (serverDataIsNewer && localDataIsOlder)) {
      reset(defaultValues)
      setLastServerUpdatedAt(serverUpdatedAt)
      setCurrentEntityId(entity.id)
    }

    // Watch form changes and debounce updates
    const subscription = watch(() => {
      return handleSubmit((data) => {
        setLastLocalUpdatedAt(new Date())
        debouncedUpdateEntity(entity.id, data)
      })()
    })

    // Cleanup function
    return () => subscription.unsubscribe()
  }, [
    currentEntityId,
    debouncedUpdateEntity,
    handleSubmit,
    entity,
    lastLocalUpdatedAt,
    lastServerUpdatedAt,
    reset,
    watch,
    defaultValues,
  ])

  return useFormResult
}

This is how it appears when the hook is in use:

const IdeaForm = (props: Props) => {
  ...
  const { control } = useCollabForm({
    entity: idea,
    defaultValues: {
      name: idea?.name || '',
      category: idea?.category || '',
      effort: idea?.effort || '',
      description: idea?.description || '',
    },
    updateEntity: updateIdea,
  })

  if (!idea) {
    return null
  }

  return (
	  <Controller
		name="name"
		control={control}
		render={({ field: { value, onChange, onBlur } }) => (
		  <input
			className="flex-1 text-ellipsis text-lg font-medium leading-6 text-gray-900 outline-0"
			placeholder="Idea Name"
			value={value}
			onChange={onChange}
			onBlur={onBlur}
		  />
		)}
	  />
	  ...

P.S.: If you have any suggestions for improving this hook, please feel free to share!

What's next

To enhance the form, I intend to introduce a "form presence" feature. This will show the real-time selections made by various users within the interactive form. By doing so, users will be able to see when an input field is being altered by someone else, providing clarity on why an input may be changing.

What it could look like:

Image found on https://supabase.com/realtime