Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

How we simplified our React components using Apollo Client and JavaScript Classes

With the release of BindPlane 1.15.0 observIQ has introduced a new Rollouts flow. Now, a user can apply configuration changes to agents in a safer and more observable way. This new feature posed some interesting challenges on the front-end, necessitating creative programming to keep our frontend simple, readable, and well tested.

The Problem

On a single Configuration page lies our ConfigurationEditor component. This component is at the very core of functionality for BindPlane OP, allowing users to view their current Configuration version, edit a new Configuration version, and inspect historical versions.

This component controls which tab a user sees, as well as the selected telemetry pipeline (i.e. Logs, Metrics, or Traces).

Determining the initial state of this component isn’t straightforward as it depends on data we’ve fetched from our server via the Apollo GraphQL client. Below is a simplified version of what this logic might look like.

export const ConfigurationEditor: React.FC = ({
  configurationName,
  isOtel,
  hideRolloutActions,
}) => {
  // Declare stateful variables, `tab`, `selectedTelemetry`, and `selectedVersion`.

  // Fetch our data
  const { data, refetch } = useGetConfigurationVersionsQuery({
    variables: {
      name: configurationName,
    },
  });

  // Begin our logic for finding versions for pending, latest, new, and history.
  const pendingVersion = data?.configurationHistory.find(
    (version) => version.status.pending && !version.status.current
  );

  const currentVersion = data?.configurationHistory.find(
    (version) => version.status.current
  );

  const newVersion = data?.configurationHistory.find(
    (version) =>
      version.status.latest &&
      !version.status.pending &&
      !version.status.current
  );

  const versionHistory = data?.configurationHistory.filter(
    (version) =>
      !version.status.latest &&
      !version.status.current &&
      !version.status.pending
  );

  // useEffect to update the stateful variables based on the data
  useEffect(() => {
    // Determine which tab to display
    if (newVersion) {
      setTab("new");
    } else if (pendingVersion) {
      setTab("pending");
    } else if (currentVersion) {
      setTab("current");
    }

    // Set the selected version based on the latest in version history
    const latestVersionHistory = versionHistory?.reduce((prev, current) =>
      prev.metadata.version > current.metadata.version ? prev : current
    ).metadata.version;

    setSelectedVersion(latestVersionHistory);

    // Set the selected telemetry type
    if (
      currentVersion &&
      currentVersion.activeTypes &&
      currentVersion.activeTypes.length > 0
    ) {
      setSelectedTelemetry(currentVersion.activeTypes[0]);
    } else if (versionHistory) {
    }

    if (versionHistory) {
      const latest = versionHistory[0];
      if (latest && latest.activeTypes && latest.activeTypes.length > 0) {
        setSelectedTelemetry(latest.activeTypes[0]);
      }
    }
  }, [newVersion, currentVersion, pendingVersion, versionHistory]);

  return {/* Render our sub components with this data */}>;
};

Alright, what’s going on here. We:

  1. Determine several variables (e.g. newVersion) based on the data we receive.
  2. Used a useEffect hook to set our stateful variables when those variables change.

It works, but we see some issues right away:

  • It’s hard to test. This will require a lot of mocked GraphQL responses to make sure we’re displaying the correct state.
  • It’s hard to read. This maze of .find and data?. is sure to be glossed over. When code isn’t read it isn’t understood, and more likely to be broken.
  • We’re unsure if our data is defined or not. It’s not clear that our data variable has returned from the request. It’s harder to use attributes on this data in sub components.

The Solution

We’re going to clean this up, make it more readable, testable, and be sure that our data is defined.

Define a Data Class

At observIQ we’re a big Go shop. One of the most powerful paradigms with Go is the ability to define methods on data structures. We can do this in a similar way in JavaScript by using classes. Check out this helper class.

export class VersionsData implements GetConfigurationVersionsQuery {
  configurationHistory: GetConfigurationVersionsQuery["configurationHistory"];
  constructor(data: GetConfigurationVersionsQuery) {
    this.configurationHistory = data.configurationHistory;
  }
}

What did we do?

  1. We defined a class that implements our GetConfigurationVersionsQuery type. That is, we now have class with all the fields of our query, and now we can add some functions to help us work with the data.
  2. We are constructing the class with a data argument that is required to be defined. We can be sure that this data is present and ok to work with.

For example we can add a helper class to find our newVersion.

export class VersionsData implements GetConfigurationVersionsQuery {
  configurationHistory: GetConfigurationVersionsQuery["configurationHistory"];
  constructor(data: GetConfigurationVersionsQuery) {
    this.configurationHistory = data.configurationHistory;
  }

  /**
   * findNewVersion returns the latest version if it is not pending or stable
   */
  findNew() {
    return this.configurationHistory.find(
      (version) =>
        version.status.latest &&
        !version.status.pending &&
        !version.status.current
    );
  }
}

Why is this better?

  1. This class is easily unit testable. We can test that we are correctly determining these versions based upon our data, rather than a mocked response in a component test.
  2. It reduces lines and logic in our component, which we want to keep simple and readable.

Let’s rewrite our component using these helper functions.

export const ConfigurationEditor: React.FC = ({
  configurationName,
  isOtel,
  hideRolloutActions,
}) => {
  // Declare stateful variables, `tab`, `selectedTelemetry`, and `selectedVersion`.

  // Fetch our data
  const { data, refetch } = useGetConfigurationVersionsQuery({
    variables: {
      name: configurationName,
    },
  });

  // useEffect to update the stateful variables based on the data
  useEffect(() => {
    if (!data) {
      return;
    }

    const versionsData = new VersionsData(data);

    if (versionsData.findNew()) {
      setTab("new");
    } else if (versionsData.findPending()) {
      setTab("pending");
    } else if (versionsData.findCurrent()) {
      setTab("current");
    }

    // find the highest version number in history
    setSelectedVersion(versionsData.latestHistoryVersion());

    // Set the selected telemetry type
    setSelectedTelemetry(
      versionsData.firstActiveType() ?? DEFAULT_TELEMETRY_TYPE
    );
  }, [data]);

  return {/* Render our sub components with this data */}>;
};

Hey! That’s looking a lot better. We got rid of an entire block of logic inside the components render cycle and put everything in our useEffect. However, we can still make further improvements.

Change out our useEffect for the onCompleted callback.

React’s useEffect hook is a powerful tool to update our state based on changing Variables. However it’s not quite right in this case, as can be seen by the smelly if statement:

if (!data) {
  return;
}

Instead let’s use the handy onCompleted callback available in our query. Something like this:

export const ConfigurationEditor: React.FC = ({
  configurationName,
  isOtel,
  hideRolloutActions,
}) => {
  const [versionsData, setVersionsData] = useState();
  // Declare stateful variables, `tab`, `selectedTelemetry`, and `selectedVersion`.

  // Fetch our data
  const { refetch } = useGetConfigurationVersionsQuery({
    variables: {
      name: configurationName,
    },
    onCompleted(data) {
      const newVersionsData = new VersionsData(data);
      setSelectedVersion(newVersionsData.latestHistoryVersion());

      if (newVersionsData.findNew()) {
        setTab("new");
      } else if (newVersionsData.findPending()) {
        setTab("pending");
      } else if (newVersionsData.findCurrent()) {
        setTab("current");
      }
      setVersionsData(newVersionsData);
      setSelectedTelemetry(
        newVersionsData.firstActiveType() ?? DEFAULT_TELEMETRY_TYPE
      );
    },
  });

  return {/* Render our sub components with this data */}>;
};

What have we done?

  1. We created a new stateful variable that contains our VersionsData class. We are setting it based upon data we receive in onCompleted.
  2. We took all the logic from our useEffect and placed it in onCompleted.

Why is this better?

We know data is defined. This onCompleted callback requires that our data has returned without error.

We only do this logic once. We only determine the initial state when our data comes in – not on first render.

Summary

By utilizing Javascript classes and the onCompleted callback we have taken our front-end logic out of the component. React components are easiest to understand when they contain only their React-y things, like stateful variables and handler functions.

Sometimes complex logic in the front-end is unavoidable – but we found this pattern to be incredibly beneficial in simplifying our React components, improving readability, and enhancing testability.

The post How we simplified our React components using Apollo Client and JavaScript Classes appeared first on observIQ.



This post first appeared on ObservIQ Blog Posts | Log Management Made Simple, please read the originial post: here

Share the post

How we simplified our React components using Apollo Client and JavaScript Classes

×

Subscribe to Observiq Blog Posts | Log Management Made Simple

Get updates delivered right to your inbox!

Thank you for your subscription

×