Skip to content

Taming Forms With react-hook-form

Taming Forms With react-hook-form

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Taming Forms With react-hook-form

After some time of doing repetitive tasks like handling forms in React.js, you will notice that there is a lot of boilerplate code that can be abstracted into reusable custom hooks. Luckily, there is plenty of existing Open Source solutions. In this case, we will be using react-hook-form.

What is react-hook-form

react-hook-form is a performant, flexible, and extensible form handling library built for React. It exports a custom hook that you can call within your Functional Components, and returns both a register function that you pass as a ref to your input components, and a handleSubmit function to wrap your submit callback.

By returning a register function that will be added to the input component, we can leverage the Uncontrolled Component pattern to make our application faster and more performant, by avoiding unnecessary re-renders.

What are we going to build?

To get a better understanding of what react-hook-form can do, we will build a simple application showing a list of characters and a form to add them to our list.

Screen-Recording-2020-10-19-at-1

Application Setup

Before going right into react-hook-form, we will need to prepare our application with the basic file structure and functionality. For this, we will create a new react application (you can either use your preferred starter or cloud IDE).

If you want to skip the application setup, you can go ahead and fork this CodeSandbox, but I highly recommend you at least read this section to have a better understanding of what the app does.

1. Characters List

Let's start by creating a new component where we will display our characters.

character-list.js

import React from "react";

function CharacterList({ characters }) {
  return (
    <div>
      <h2>Character List</h2>

      {characters.length === 0 ? (
        <p>
          <em>Your character list is empty</em>
        </p>
      ) : (
        <ul>
          {characters.map((character, id) => (
            <li key={id}>
              {character.name} (<strong>{character.species}</strong>)
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default CharacterList;

If you have a basic understanding of React, you will notice our CharacterList component will receive a characters prop, which is an array of objects with the properties name and species. If the array is empty, we will render a placeholder. Elsewhere, we will render the list.

2. Add Character Form

The second step is to create a component that will render the form to add a new character to our list.

character-form.js

import React from "react";

function CharacterForm({ addCharacter }) {
  const onSubmit = (data) => {
    addCharacter(data);
  };

  return (
    <div>
      <h2>Add character</h2>
      <form onSubmit={onSubmit}>
        <div>
          <input name="name" placeholder="Character name" />
        </div>
        <div>
          <select name="species">
            <option value="sorcerer">Sorcerer</option>
            <option value="knight">Knight</option>
            <option value="human">Human</option>
          </select>
        </div>
        <div>
          <button type="submit">Add character</button>
        </div>
      </form>
    </div>
  );
}

export default CharacterForm;

By itself, this component won't do anything because we are not doing anything with the collected data, nor validating our fields. This will be the component where we will be working on the next section of this tutorial.

3. The App

Now, let's just create the App component where we will render CharacterList and CharacterForm.

app.js

import React from "react";

import CharacterList from "./character-list";
import CharacterForm from "./character-form";

function App() {
  const [characters, setCharacters] = React.useState([]);

  const addCharacter = (character) => {
    setCharacters((characters) => [...characters, character]);
  };

  return (
    <div>
      <CharacterList characters={characters} />
      <hr />
      <CharacterForm addCharacter={addCharacter} />
    </div>
  );
}

export default App;

We will be saving our character list in characters by using the React.useState hook, and passing them down to CharacterList. Also, we created an addCharacter function that will just add a new character at the end of the characters list, and pass it to CharacterForm via prop.

Let's get to it!

Now that we have our application setup, let's see how can we leverage react-hook-form to take our forms to the next level.

Install react-hook-form

yarn add react-hook-form

Add react-hook-form to your CharacterForm

Here comes the fun. First, let's import useForm from react-hook-form, call the hook in our component, destructure register and handleSubmit methods out of it (don't worry, I will explain what they do just in a while), wrap our onSubmit function with handleSubmit, and pass register as the ref for each one of our form controls.

character-form.js

import React from "react";
+import { useForm } from "react-hook-form";

function CharacterForm({ addCharacter }) {
+  const { register, handleSubmit } = useForm();
+
-  const onSubmit = (data) => {
-    addCharacter(data);
-  };
+  const onSubmit = handleSubmit((data) => {
+    addCharacter(data);
+  });

  return (
    <div>
      <h2>Add character</h2>
      <form onSubmit={onSubmit}>
        <div>
-          <input name="name" placeholder="Character name" />
+          <input ref={register} name="name" placeholder="Character name" />
        </div>
        <div>
-          <select name="species">
+          <select ref={register} name="species">
            <option value="sorcerer">Sorcerer</option>
            <option value="knight">Knight</option>
            <option value="human">Human</option>
          </select>
        </div>
        <div>
          <button type="submit">Add character</button>
        </div>
      </form>
    </div>
  );
}

export default CharacterForm;

The register method

By attaching the register ref to our form controls, we can start tracking some stuff like the field value, its validation status, and even if the field had been touched or not.

Important: the name prop is required when passing the register ref, and it should be unique. This way, react-hook-form will know where to assign the field value. For more information, check out the register documentation.

The handleSubmit method

This is a function that wraps our submit callback, and passes the actual form values to it. Under the hood, it also calls preventDefault on the form event to avoid full page reloads. It can also be an asynchronous function.

For more information, check out the handleSubmit documentation.

Add some validations

At this point, we have a working form that is able to add characters to our list. However, we are not checking if the field is filled, in order to avoid empty submissions.

With react-hook-form, it is as simple as calling the register function with a configuration object defining the validation rules. For our case, we will make the name field required. Also, we can extract errors from useForm to show the user if the field has errors.

import React from "react";
import { useForm } from "react-hook-form";

function CharacterForm({ addCharacter }) {
-  const { register, handleSubmit } = useForm();
+  const { register, handleSubmit, errors } = useForm();

  const onSubmit = handleSubmit((data) => {
    addCharacter(data);
  });

  return (
    <div>
      <h2>Add character</h2>
      <form onSubmit={onSubmit}>
        <div>
-          <input ref={register} name="name" placeholder="Character name" />
+          <input
+            ref={register({ required: true })}
+            name="name"
+            placeholder="Character name"
+          />
+          {errors.name && errors.name.type === "required"
+            ? "Name is required"
+            : null}
        </div>
        <div>
          <select ref={register} name="species">
            <option value="sorcerer">Sorcerer</option>
            <option value="knight">Knight</option>
            <option value="human">Human</option>
          </select>
        </div>
        <div>
          <button type="submit">Add character</button>
        </div>
      </form>
    </div>
  );
}

export default CharacterForm;

Reset the form status

The final step is to clear our form after successfully adding a character to our character list. For that, we will destructure a new method from the useForm hook: reset, and call it after addCharacter.

import React from "react";
import { useForm } from "react-hook-form";

function CharacterForm({ addCharacter }) {
-  const { register, handleSubmit, errors } = useForm();
+  const { register, handleSubmit, errors, reset } = useForm();

  const onSubmit = handleSubmit((data) => {
    addCharacter(data);
+    reset();
  });

  console.log(errors.nameRequired);

  return (
    <div>
      <h2>Add character</h2>
      <form onSubmit={onSubmit}>
        <div>
          <input
            ref={register({ required: true })}
            name="name"
            placeholder="Character name"
          />
          {errors.name && errors.name.type === "required"
            ? "Name is required"
            : null}
        </div>
        <div>
          <select ref={register} name="species">
            <option value="sorcerer">Sorcerer</option>
            <option value="knight">Knight</option>
            <option value="human">Human</option>
          </select>
        </div>
        <div>
          <button type="submit">Add character</button>
        </div>
      </form>
    </div>
  );
}

export default CharacterForm;

For more information, check out the reset documentation.

Moving forward

Now that you have a better sense of how to manage your React forms, you have unlocked a new world of possibilities by using battle-tested and community-validated libraries like react-hook-form.

You can take a look at more advanced use cases, additional resources or even take a look at the full API.

If you want a finished code sample, you can check out this CodeSandbox.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

You might also like

Why is My React Reducer Called Twice and What the Heck is a Pure Function? cover image

Why is My React Reducer Called Twice and What the Heck is a Pure Function?

Why is My React Reducer Called Twice and What the Heck is a Pure Function? In a recent project, we encountered an interesting issue: our React reducer was dispatching twice, producing incorrect values, such as incrementing a number in increments of two. We hopped on a pairing session and started debugging. Eventually, we got to the root of the problem and learned the importance of pure functions in functional programming. This article will explain why our reducer was being dispatched twice, what pure functions are, and how React's strict mode helped us identify a bug in our code. The Issue We noticed that our useReducer hook was causing the reducer function to be called twice for every action dispatched. Initially, we were confused about this behavior and thought it might be a bug in React. Additionally, we had one of the dispatches inside a useEffect, which caused it to be called twice due to React strict mode, effectively firing the reducer four times and further complicating our debugging process. However, we knew that React's strict mode caused useEffect to be called twice, so it didn't take very long to realize that the issue was not with React but with how we had implemented our reducer function. React Strict Mode React's strict mode is a tool for highlighting potential problems in an application. It intentionally double-invokes specific lifecycle methods and hooks (like useReducer and useEffect) to help developers identify side effects. This behavior exposed our issue, as we had reducers that were not pure functions. What is a Pure Function? A pure function is a function that: - Is deterministic: Given the same input, always returns the same output. - Does Not Have Side Effects: Does not alter any external state or have observable interactions with the outside world. In the context of a reducer, this means the function should not: - Modify its arguments - Perform any I/O operations (like network requests or logging) - Generate random numbers - Depend on any external state Pure functions are predictable and testable. They help prevent bugs and make code easier to reason about. In the context of React, pure functions are essential for reducers because they ensure that the state transitions are predictable and consistent. The Root Cause: Impure Reducers Our reducers were not pure functions. They were altering external state and had side effects, which caused inconsistent behavior when React's strict mode double-invoked them. This led to unexpected results and made debugging more difficult. The Solution: Make Reducers Pure To resolve this issue, we refactored our reducers to ensure they were pure functions. Here's an extended example of how we transformed an impure reducer into a pure one in a more complex scenario involving a task management application. Let's start with the initial state and action types: ` And here's the impure reducer similar to what we had initially: ` This reducer is impure because it directly modifies the state object, which is a side effect. To make it pure, we must create a new state object for every action and return it without modifying the original state. Here's the refactored pure reducer: ` Key Changes: - Direct State Modification: In the impure reducer, the state is directly modified (e.g., state.tasks.push(action.payload)). This causes side effects and violates the principles of pure functions. - Side Effects: The impure reducer included side effects such as logging and direct state changes. The pure reducer eliminates these side effects, ensuring consistent and predictable behavior. I've created an interactive example to demonstrate the difference between impure and pure reducers in a React application. Despite the RESET_TASKS action being implemented similarly in both reducers, you'll notice that the impure reducer does not reset the tasks correctly. This problem happens because the impure reducer directly modifies the state, leading to unexpected behavior. Check out the embedded StackBlitz example below: Conclusion Our experience with the reducer dispatching twice was a valuable lesson in the importance of pure functions in React. Thanks to React's strict mode, we identified and fixed impure reducers, leading to more predictable and maintainable code. If you encounter similar issues, ensure your reducers are pure functions and leverage React strict mode to catch potential problems early in development. By embracing functional programming principles, you can write cleaner, more reliable code that is easier to debug and maintain....

Remix's evolution to a Vite plugin cover image

Remix's evolution to a Vite plugin

The Remix / React Router news is a beautiful illustration of software evolution. You start with a clear idea of what you’re building, but it’s impossible to anticipate exactly what it will become. If you haven’t heard the news yet, Ryan Florence announced at React Conf 2024 that Remix will be a Vite plugin for React Router. It’s a sort of surprising announcement but it makes a lot of sense if you’ve been following along as Remix the project has evolved. In this post we’ll recap the evolution of the project and then dig into what this change means for the future of Remix in the Server Components era of React. šŸ—“ļø October 2021 > Remix is open sourced In the beginning, Remix cost money. In October 2021 they received some seed funding and the project was open sourced. Remix was an exciting new framework for server-rendering React websites that included API’s for loading data on the server and submitting data to the server with forms. It started a trend in the front-end framework space that a lot of other popular frameworks have modeled after or taken inspiration from. šŸ—“ļø March 2022 - September 2022 > Remixing React Router At some point they realized that the API’s that they had created for Remix were a more natural fit in React Router. In this post Ryan details the plans to move these Remix API’s into React Router. Several months later, React Router 6.4 is released with the action, loader, and other Remix API’s baked in. > "We've always thought of Remix as "just a compiler and server for React Router" After this release, Remix is now mostly as they described - a compiler and server for React Router. šŸ—“ļø October 2023 - February 2024 > Remix gets Vite support If you’re not familiar with Vite, it’s an extremely popular tool for front-end development that handles things like development servers and code bundling/compiling. A lot of the modern front-end frameworks are built on top of it. Remix announced support for Vite as an alternative option to their existing compiler and server. Since Remix is now mostly ā€œjust a compiler and server for React Routerā€ - what’s left of Remix now that Vite can handle those things? šŸ—“ļø May 2024 > Remix is React Router The Remix team releases a post detailing the announcement that Ryan made at React Conf. Looking back through the evolution of the project this doesn’t seem like an out of the blue, crazy announcement. Merging Remix API’s into React Router and then replacing the compiler and server with Vite didn’t leave much for Remix to do as a standalone project. 🐐 React Router - the past, present, and future of React React Router is one of the oldest, and most used packages in the React ecosystem. > Since Remix has always been effectively "React Router: The Framework", we wanted to create a bridge for all these React Router projects to be able to upgrade to Remix. With Remix being baked into React Router and now supporting SPA mode, legacy React Router applications have an easier path for migrating to the next generation of React. https://x.com/ryanflorence/status/1791488550162370709 Remix vs React 19 If you’ve taken a look at React 19 at all, it turns out it now handles a lot of the problems that Remix set out to solve in the first place. Things like Server Components, Server Actions, and Form Actions provide the same sort of programming model that Remix popularized. https://x.com/ryanflorence/status/1791484663883829258 Planned Changes for Remix and React Router Just recently, a post was published giving the exact details of what the changes to Remix and React Router will look like. tl;dr For React Router * React Router v6 to v7 will be a non-breaking upgrade * The Vite plugin from Remix is coming to React Router in v7 * The Vite plugin simply makes existing React Router features more convenient to use, but it isn't required to use React Router v7 * v7 will support both React 18 and React 19 For Remix * What would have been Remix v3 is React Router v7 * Remix v2 to React Router v7 will be a non-breaking upgrade * Remix is coming back better than ever in a future release with an incremental adoption strategy enabled by these changes For Both * React Router v7 comes with new features not in Remix or React Router today: RSC, server actions, static pre-rendering, and enhanced Type Safety across the board The show goes on I love how this project has evolved and I tip my hat to the Remix/React Router team. React Router feels like a cozy blanket in an ecosystem that is going through some major changes which feels uncertain and a little scary at times. The Remix / React Router story is still being written and I’m excited to see where it goes. I’m looking forward to seeing the work they are doing to support React Server Components. A lot of people are pretty excited about RSC’s and there is a lot of room for unique and interesting implementations. Long live React Router šŸ™Œ....

React.js 17: No New Features and New JSX Transform cover image

React.js 17: No New Features and New JSX Transform

React.js 17: No New Features and New JSX Transform When React.js 17 became available on October 20th, it generated some confusion because it has no new developer-facing features. Instead, the React Core team focused on making the React library easier to self-upgrade in the future. This means, that if your app is running React 17 and you need to upgrade some features to React 18, you can move your application core to React 18 and lazily load the legacy code without any issues. Of course, you can still upgrade your entire app the new version at once, which is the recommended version by the React team. Installation To install React 17 with npm, run: ` To install React 17 with Yarn, run: ` Internal improvements New JSX Transform One of the greatest internal improvements React 17 has is the new JSX transform. Prior to React 17, when you write JSX code, it was transformed to a React.createElement call. So, it looked something like this: ` Was transformed to this: ` Starting on React 17, you don't need to import React from 'react' anymore. Instead, two new entry points were added to the React package that are intended to be used only by compilers like Babel or TypeScript. So for the above JSX code, the output will be slightly different: ` So, your app won't need to import React from 'react' anymore. Instead, you can directly write your JSX code: ` Next.js was known to allow the above because under the hood, it imported React. However, they changed this in their 9.5.3 release in order to use the new transform. Updating ESlint rules If you are using the eslint-plugin-react package, you may notice not importing React will generate an issue. For that, you can just disable those rules while they remove those rules. eslintrc.json ` Removing Unused React Imports Luckily, the React team has created a codemod you can run to remove your old React imports. To run it, just open a new terminal on your project directory, and run the following script: ` A new Event Delegation System To enable gradual updates, the React team changed its internal *Event Delegation System*, which works like this: Before React 17, all event handlers were attached at the document level under the hood, so it ran document.addEventListener() for most events. After React 17, it attached all event handlers to the root element where you render your app, which calls rootNode.addEventListener() instead. To better understand how the new *Event Delegation System* works, take a look at this diagram: You can take a look at a demo of gradual updates on an example repository put together by the React team. Other Breaking Changes There are other minor breaking changes that hopefully won't affect your application. They are mostly internal changes, like the onScroll event no longer bubbling, onFocus and onBlur events switching to use the native focusin and focusout under the hood, removing the old event pooling optimization, and making the effect cleanup timing more consistent. To go deeper into these other breaking changes, you can take a look at the React v17 RC Breaking Changes....

The Importance of a Scientific Mindset in Software Engineering: Part 2 (Debugging) cover image

The Importance of a Scientific Mindset in Software Engineering: Part 2 (Debugging)

The Importance of a Scientific Mindset in Software Engineering: Part 2 (Debugging) In the first part of my series on the importance of a scientific mindset in software engineering, we explored how the principles of the scientific method can help us evaluate sources and make informed decisions. Now, we will focus on how these principles can help us tackle one of the most crucial and challenging tasks in software engineering: debugging. In software engineering, debugging is often viewed as an art - an intuitive skill honed through experience and trial and error. In a way, it is - the same as a GP, even a very evidence-based one, will likely diagnose most of their patients based on their experience and intuition and not research scientific literature every time; a software engineer will often rely on their experience and intuition to identify and fix common bugs. However, an internist faced with a complex case will likely not be able to rely on their intuition alone and must apply the scientific method to diagnose the patient. Similarly, a software engineer can benefit from using the scientific method to identify and fix the problem when faced with a complex bug. From that perspective, treating engineering challenges like scientific inquiries can transform the way we tackle problems. Rather than resorting to guesswork or gut feelings, we can apply the principles of the scientific method—forming hypotheses, designing controlled experiments, gathering and evaluating evidence—to identify and eliminate bugs systematically. This approach, sometimes referred to as "scientific debugging," reframes debugging from a haphazard process into a structured, disciplined practice. It encourages us to be skeptical, methodical, and transparent in our reasoning. For instance, as Andreas Zeller notes in the book _Why Programs Fail_, the key aspect of scientific debugging is its explicitness: Using the scientific method, you make your assumptions and reasoning explicit, allowing you to understand your assumptions and often reveals hidden clues that can lead to the root cause of the problem on hand. Note: If you'd like to read an excerpt from the book, you can find it on Embedded.com. Scientific Debugging At its core, scientific debugging applies the principles of the scientific method to the process of finding and fixing software defects. Rather than attempting random fixes or relying on intuition, it encourages engineers to move systematically, guided by data, hypotheses, and controlled experimentation. By adopting debugging as a rigorous inquiry, we can reduce guesswork, speed up the resolution process, and ensure that our fixes are based on solid evidence. Just as a scientist begins with a well-defined research question, a software engineer starts by identifying the specific symptom or error condition. For instance, if our users report inconsistencies in the data they see across different parts of the application, our research question could be: _"Under what conditions does the application display outdated or incorrect user data?"_ From there, we can follow a structured debugging process that mirrors the scientific method: - 1. Observe and Define the Problem: First, we need to clearly state the bug's symptoms and the environment in which it occurs. We should isolate whether the issue is deterministic or intermittent and identify any known triggers if possible. Such a structured definition serves as the groundwork for further investigation. - 2. Formulate a Hypothesis: A hypothesis in debugging is a testable explanation for the observed behavior. For instance, you might hypothesize: _"The data inconsistency occurs because a caching layer is serving stale data when certain user profiles are updated."_ The key is that this explanation must be falsifiable; if experiments don't support the hypothesis, it must be refined or discarded. - 3. Collect Evidence and Data: Evidence often includes logs, system metrics, error messages, and runtime traces. Similar to reviewing primary sources in academic research, treat your raw debugging data as crucial evidence. Evaluating these data points can reveal patterns. In our example, such patterns could be whether the bug correlates with specific caching mechanisms, increased memory usage, or database query latency. During this step, it's essential to approach data critically, just as you would analyze the quality and credibility of sources in a research literature review. Don't forget that even logs can be misleading, incomplete, or even incorrect, so cross-referencing multiple sources is key. - 4. Design and Run Experiments: Design minimal, controlled tests to confirm or refute your hypothesis. In our example, you may try disabling or shortening the cache's time-to-live (TTL) to see if more recent data is displayed correctly. By manipulating one variable at a time - such as cache invalidation intervals - you gain clearer insights into causation. Tools such as profilers, debuggers, or specialized test harnesses can help isolate factors and gather precise measurements. - 5. Analyze Results and Refine Hypotheses: If the experiment's outcome doesn't align with your hypothesis, treat it as a stepping stone, not a dead end. Adjust your explanation, form a new hypothesis, or consider additional variables (for example, whether certain API calls bypass caching). Each iteration should bring you closer to a better understanding of the bug's root cause. Remember, the goal is not to prove an initial guess right but to arrive at a verifiable explanation. - 6. Implement and Verify the Fix: Once you're confident in the identified cause, you can implement the fix. Verification doesn't stop at deployment - re-test under the same conditions and, if possible, beyond them. By confirming the fix in a controlled manner, you ensure that the solution is backed by evidence rather than wishful thinking. - Personally, I consider implementing end-to-end tests (e.g., with Playwright) that reproduce the bug and verify the fix to be a crucial part of this step. This both ensures that the bug doesn't reappear in the future due to changes in the codebase and avoids possible imprecisions of manual testing. Now, we can explore these steps in more detail, highlighting how the scientific method can guide us through the debugging process. Establishing Clear Debugging Questions (Formulating a Hypothesis) A hypothesis is a proposed explanation for a phenomenon that can be tested through experimentation. In a debugging context, that phenomenon is the bug or issue you're trying to resolve. Having a clear, falsifiable statement that you can prove or disprove ensures that you stay focused on the real problem rather than jumping haphazardly between possible causes. A properly formulated hypothesis lets you design precise experiments to evaluate whether your explanation holds true. To formulate a hypothesis effectively, you can follow these steps: 1. Clearly Identify the Symptom(s) Before forming any hypothesis, pin down the specific issue users are experiencing. For instance: - "Users intermittently see outdated profile information after updating their accounts." - "Some newly created user profiles don't reflect changes in certain parts of the application." Having a well-defined problem statement keeps your hypothesis focused on the actual issue. Just like a research question in science, the clarity of your symptom definition directly influences the quality of your hypothesis. 2. Draft a Tentative Explanation Next, convert your symptom into a statement that describes a _possible root cause_, such as: - "Data inconsistency occurs because the caching layer isn't invalidating or refreshing user data properly when profiles are updated." - "Stale data is displayed because the cache timeout is too long under certain load conditions." This step makes your assumption about the root cause explicit. As with the scientific method, your hypothesis should be something you can test and either confirm or refute with data or experimentation. 3. Ensure Falsifiability A valid hypothesis must be falsifiable - meaning it can be proven _wrong_. You'll struggle to design meaningful experiments if a hypothesis is too vague or broad. For example: - Not Falsifiable: "Occasionally, the application just shows weird data." - Falsifiable: "Users see stale data when the cache is not invalidated within 30 seconds of profile updates." Making your hypothesis specific enough to fail a test will pave the way for more precise debugging. 4. Align with Available Evidence Match your hypothesis to what you already know - logs, stack traces, metrics, and user reports. For example: - If logs reveal that cache invalidation events aren't firing, form a hypothesis explaining why those events fail or never occur. - If metrics show that data served from the cache is older than the configured TTL, hypothesize about how or why the TTL is being ignored. If your current explanation contradicts existing data, refine your hypothesis until it fits. 5. Plan for Controlled Tests Once you have a testable hypothesis, figure out how you'll attempt to _disprove_ it. This might involve: - Reproducing the environment: Set up a staging/local system that closely mimics production. For instance with the same cache layer configurations. - Varying one condition at a time: For example, only adjust cache invalidation policies or TTLs and then observe how data freshness changes. - Monitoring metrics: In our example, such monitoring would involve tracking user profile updates, cache hits/misses, and response times. These metrics should lead to confirming or rejecting your explanation. These plans become your blueprint for experiments in further debugging stages. Collecting and Evaluating Evidence After formulating a clear, testable hypothesis, the next crucial step is to gather data that can either support or refute it. This mirrors how scientists collect observations in a literature review or initial experiments. 1. Identify "Primary Sources" (Logs, Stack Traces, Code History): - Logs and Stack Traces: These are your direct pieces of evidence - treat them like raw experimental data. For instance, look closely at timestamps, caching-related events (e.g., invalidation triggers), and any error messages related to stale reads. - Code History: Look for related changes in your source control, e.g. using Git bisect. In our example, we would look for changes to caching mechanisms or references to cache libraries in commits, which could pinpoint when the inconsistency was introduced. Sometimes, reverting a commit that altered cache settings helps confirm whether the bug originated there. 2. Corroborate with "Secondary Sources" (Documentation, Q&A Forums): - Documentation: Check official docs for known behavior or configuration details that might differ from your assumptions. - Community Knowledge: Similar issues reported on GitHub or StackOverflow may reveal known pitfalls in a library you're using. 3. Assess Data Quality and Relevance: - Look for Patterns: For instance, does stale data appear only after certain update frequencies or at specific times of day? - Check Environmental Factors: For instance, does the bug happen only with particular deployment setups, container configurations, or memory constraints? - Watch Out for Biases: Avoid seeking only the data that confirms your hypothesis. Look for contradictory logs or metrics that might point to other root causes. You keep your hypothesis grounded in real-world system behavior by treating logs, stack traces, and code history as primary data - akin to raw experimental results. This evidence-first approach reduces guesswork and guides more precise experiments. Designing and Running Experiments With a hypothesis in hand and evidence gathered, it's time to test it through controlled experiments - much like scientists isolate variables to verify or debunk an explanation. 1. Set Up a Reproducible Environment: - Testing Environments: Replicate production conditions as closely as possible. In our example, that would involve ensuring the same caching configuration, library versions, and relevant data sets are in place. - Version Control Branches: Use a dedicated branch to experiment with different settings or configuration, e.g., cache invalidation strategies. This streamlines reverting changes if needed. 2. Control Variables One at a Time: - For instance, if you suspect data inconsistency is tied to cache invalidation events, first adjust only the invalidation timeout and re-test. - Or, if concurrency could be a factor (e.g., multiple requests updating user data simultaneously), test different concurrency levels to see if stale data issues become more pronounced. 3. Measure and Record Outcomes: - Automated Tests: Tests provide a great way to formalize and verify your assumptions. For instance, you could develop tests that intentionally update user profiles and check if the displayed data matches the latest state. - Monitoring Tools: Monitor relevant metrics before, during, and after each experiment. In our example, we might want to track cache hit rates, TTL durations, and query times. - Repeat Trials: Consistency across multiple runs boosts confidence in your findings. 4. Validate Against a Baseline: - If baseline tests manifest normal behavior, but your experimental changes manifest the bug, you've isolated the variable causing the issue. E.g. if the baseline tests show that data is consistently fresh under normal caching conditions but your experimental changes cause stale data. - Conversely, if your change eliminates the buggy behavior, it supports your hypothesis - e.g. that the cache configuration was the root cause. Each experiment outcome is a data point supporting or contradicting your hypothesis. Over time, these data points guide you toward the true cause. Analyzing Results and Iterating In scientific debugging, an unexpected result isn't a failure - it's valuable feedback that brings you closer to the right explanation. 1. Compare Outcomes to the hypothesis. For instance: - Did user data stay consistent after you reduced the cache TTL or fixed invalidation logic? - Did logs show caching events firing as expected, or did they reveal unexpected errors? - Are there only partial improvements that suggest multiple overlapping issues? 2. Incorporate Unexpected Observations: - Sometimes, debugging uncovers side effects - e.g. performance bottlenecks exposed by more frequent cache invalidations. Note these for future work. - If your hypothesis is disproven, revise it. For example, the cache may only be part of the problem, and a separate load balancer setting also needs attention. 3. Avoid Confirmation Bias: - Don't dismiss contrary data. For instance, if you see evidence that updates are fresh in some modules but stale in others, you may have found a more nuanced root cause (e.g., partial cache invalidation). - Consider other credible explanations if your teammates propose them. Test those with the same rigor. 4. Decide If You Need More Data: - If results aren't conclusive, add deeper instrumentation or enable debug modes to capture more detailed logs. - For production-only issues, implement distributed tracing or sampling logs to diagnose real-world usage patterns. 5. Document Each Iteration: - Record the results of each experiment, including any unexpected findings or new hypotheses that arise. - Through iterative experimentation and analysis, each cycle refines your understanding. By letting evidence shape your hypothesis, you ensure that your final conclusion aligns with reality. Implementing and Verifying the Fix Once you've identified the likely culprit - say, a misconfigured or missing cache invalidation policy - the next step is to implement a fix and verify its resilience. 1. Implementing the Change: - Scoped Changes: Adjust just the component pinpointed in your experiments. Avoid large-scale refactoring that might introduce other issues. - Code Reviews: Peer reviews can catch overlooked logic gaps or confirm that your changes align with best practices. 2. Regression Testing: - Re-run the same experiments that initially exposed the issue. In our stale data example, confirm that the data remains fresh under various conditions. - Conduct broader tests - like integration or end-to-end tests - to ensure no new bugs are introduced. 3. Monitoring in Production: - Even with positive test results, real-world scenarios can differ. Monitor logs and metrics (e.g. cache hit rates, user error reports) closely post-deployment. - If the buggy behavior reappears, revisit your hypothesis or consider additional factors, such as unpredicted user behavior. 4. Benchmarking and Performance Checks (If Relevant): - When making changes that affect the frequency of certain processes - such as how often a cache is refreshed - be sure to measure the performance impact. Verify you meet any latency or resource usage requirements. - Keep an eye on the trade-offs: For instance, more frequent cache invalidations might solve stale data but could also raise system load. By systematically verifying your fix - similar to confirming experimental results in research - you ensure that you've addressed the true cause and maintained overall software stability. Documenting the Debugging Process Good science relies on transparency, and so does effective debugging. Thorough documentation guarantees your findings are reproducible and valuable to future team members. 1. Record Your Hypothesis and Experiments: - Keep a concise log of your main hypothesis, the tests you performed, and the outcomes. - A simple markdown file within the repo can capture critical insights without being cumbersome. 2. Highlight Key Evidence and Observations: - Note the logs or metrics that were most instrumental - e.g., seeing repeated stale cache hits 10 minutes after updates. - Document any edge cases discovered along the way. 3. List Follow-Up Actions or Potential Risks: - If you discover additional issues - like memory spikes from more frequent invalidation - note them for future sprints. - Identify parts of the code that might need deeper testing or refactoring to prevent similar issues. 4. Share with Your Team: - Publish your debugging report on an internal wiki or ticket system. A well-documented troubleshooting narrative helps educate other developers. - Encouraging open discussion of the debugging process fosters a culture of continuous learning and collaboration. By paralleling scientific publication practices in your documentation, you establish a knowledge base to guide future debugging efforts and accelerate collective problem-solving. Conclusion Debugging can be as much a rigorous, methodical exercise as an art shaped by intuition and experience. By adopting the principles of scientific inquiry - forming hypotheses, designing controlled experiments, gathering evidence, and transparently documenting your process - you make your debugging approach both systematic and repeatable. The explicitness and structure of scientific debugging offer several benefits: - Better Root-Cause Discovery: Structured, hypothesis-driven debugging sheds light on the _true_ underlying factors causing defects rather than simply masking symptoms. - Informed Decisions: Data and evidence lead the way, minimizing guesswork and reducing the chance of reintroducing similar issues. - Knowledge Sharing: As in scientific research, detailed documentation of methods and outcomes helps others learn from your process and fosters a collaborative culture. Ultimately, whether you are diagnosing an intermittent crash or chasing elusive performance bottlenecks, scientific debugging brings clarity and objectivity to your workflow. By aligning your debugging practices with the scientific method, you build confidence in your solutions and empower your team to tackle complex software challenges with precision and reliability. But most importantly, do not get discouraged by the number of rigorous steps outlined above or by the fact you won't always manage to follow them all religiously. Debugging is a complex and often frustrating process, and it's okay to rely on your intuition and experience when needed. Feel free to adapt the debugging process to your needs and constraints, and as long as you keep the scientific mindset at heart, you'll be on the right track....

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co