DEV Community

Cover image for Creating Dynamic Widget Grids with React: A Guide to Building and Customizing
Francisco Mendes
Francisco Mendes

Posted on • Edited on

Creating Dynamic Widget Grids with React: A Guide to Building and Customizing

Introduction

In today's article we are going to create a simple widget grid in react where we can drag elements in a specific area (in the article it is the "whole" screen) and we will be able to resize some of them.

In addition to this I think it makes sense to save the positions and dimensions of each one so we will also persist the data between sessions (locally).

At the end of the article you will have a result similar to the following:

widgets

What are we going to use?

Today we are not going to use many tools out of the ordinary, such as:

  • React-Grid-Layout - draggable and resizable grid layout
  • Stitches - a css-in-js styling library with phenomenal development experience
  • Remeda - a utility library that provides a set of functions that will help us deal with strings, objects and arrays
  • use-local-storage - a react Hook for using Local Storage nicely

Bear in mind that although these are the libraries used in this article, the same result is also easily replicable with others.

Prerequisites

To follow this tutorial, you need:

  • Basic understanding of React
  • Basic understanding of TypeScript

You won't have any problem if you don't know TypeScript, you can always "ignore" the data types, however in today's example it makes the whole process much easier.

Getting Started

As a first step, create a project directory and navigate into it:

yarn create vite react-dnd --template react-ts
cd react-dnd
Enter fullscreen mode Exit fullscreen mode

Now we can install the necessary dependencies:

yarn add use-local-storage remeda react-grid-layout @stitches/react @fontsource/anek-telugu
yarn add -D @types/react-grid-layout
Enter fullscreen mode Exit fullscreen mode

Now that we have the boilerplate and the necessary dependencies installed, we can start by creating some base components and these components will be the widgets.

In today's article we will only have two types of widgets, normal and resizable. Starting by creating the NormalWidget.tsx:

// @/src/components/NormalWidget.tsx
import { FC } from "react";
import { styled } from "@stitches/react";

interface Props {
  title?: string;
}

const Box = styled("div", {
  background: "#5CB8E4",
  borderRadius: 12,
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  height: "100%",
  width: "100%",
  color: "#F2F2F2",
  fontFamily: "Anek Telugu",
  cursor: "move",
});

export const NormalWidget: FC<Props> = ({ title = "Normal Widget" }) => {
  return (
    <Box>
      <h2>{title}</h2>
    </Box>
  );
};
Enter fullscreen mode Exit fullscreen mode

Then we can create the next widget that will be ResizableWidget.tsx, its data will be passed by the props and the only thing we will have inside the component is a function that will be responsible for updating the widget's dimension:

// @/src/components/ResizableWidget.tsx
import { FC, useCallback } from "react";
import { styled } from "@stitches/react";

import { LayoutSizes, WidgetIdentifiers } from "../App";

interface Props {
  title?: string;
  identifier: WidgetIdentifiers;
  size?: LayoutSizes;
  onUpdate?: (identifier: WidgetIdentifiers, size: LayoutSizes) => void;
}

const Box = styled("div", {
  background: "#8758FF",
  borderRadius: 12,
  display: "flex",
  justifyContent: "center",
  flexDirection: "column",
  alignItems: "center",
  height: "100%",
  width: "100%",
  color: "#F2F2F2",
  fontFamily: "Anek Telugu",
  cursor: "move",
});

const Button = styled("button", {
  border: "none",
  height: 30,
  width: "fit-content",
  borderRadius: 8,
  color: "#F2F2F2",
  background: "#181818",
  cursor: "pointer",
});

export const ResizableWidget: FC<Props> = ({
  title = "Resizable Widget",
  identifier,
  size = "sm",
  onUpdate,
}) => {
  const onResizeHandler = useCallback(() => {
    const newSize = size === "sm" ? "lg" : "sm";
    onUpdate?.(identifier, newSize);
  }, [identifier, size, onUpdate]);

  return (
    <Box>
      <h2>{title}</h2>
      <Button onClick={onResizeHandler}>
        Change to {size === "sm" ? "large" : "small"}
      </Button>
    </Box>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we can go to App.tsx and define everything that will be needed from configurations to logic. First we are going to import the necessary dependencies, as well as the components we just created.

// @/src/App.tsx
import "@fontsource/anek-telugu";
import { useCallback, useEffect, useMemo } from "react";
import GridLayout, { Layout } from "react-grid-layout";
import useLocalStorage from "use-local-storage";
import * as R from "remeda";

import { NormalWidget } from "./components/NormalWidget";
import { ResizableWidget } from "./components/ResizableWidget";

// ...
Enter fullscreen mode Exit fullscreen mode

The next step will be to create an enum with the names of each of the widgets, which in this case are purchase, transaction and earnings.

Just as we are going to create an enum with the name of each of the keys that we will have in localstorage and each one of them will store different data, one of the keys will store the data related to the widgets and the other the data related to the layout.

// @/src/App.tsx
import "@fontsource/anek-telugu";
import { useCallback, useEffect, useMemo } from "react";
import GridLayout, { Layout } from "react-grid-layout";
import useLocalStorage from "use-local-storage";
import * as R from "remeda";

import { NormalWidget } from "./components/NormalWidget";
import { ResizableWidget } from "./components/ResizableWidget";


export enum WidgetIdentifiers {
  PURCHASE = "purchase",
  TRANSACTION = "transaction",
  EARNINGS = "earnings",
}

enum StorageKeys {
  WIDGETS = "widgets",
  LAYOUT = "layout",
}

// ...
Enter fullscreen mode Exit fullscreen mode

Moving on to the layout definition, we can stipulate different dimensions for each of the widgets, but we are going to define certain rules. In how we can only have two sizes sm and lg. Then in each of these sizes we define their respective dimensions in the grid, like this:

// @/src/App.tsx

// ...

type LayoutPropsOnly = Omit<Layout, "i">;
export type LayoutSizes = "sm" | "lg";

const WIDGET_LAYOUTS: Record<
  WidgetIdentifiers,
  Partial<Record<LayoutSizes, LayoutPropsOnly>>
> = {
  purchase: {
    sm: { x: 0, y: 0, w: 2, h: 2 },
  },
  transaction: {
    sm: { x: 4, y: 0, w: 2, h: 2 },
  },
  earnings: {
    sm: { x: 2, y: 0, w: 2, h: 2 },
    lg: { x: 2, y: 0, w: 4, h: 2 },
  },
};

// ...
Enter fullscreen mode Exit fullscreen mode

With the layout defined we can now define the initial state of the widgets and initially we will only save the name of each of the widgets and as we change the dimensions of the widgets we can update the objects and their respective properties.

// @/src/App.tsx

// ...

interface CustomWidget {
  indentifier: WidgetIdentifiers;
  size?: LayoutSizes;
}

const DEFAULT_WIDGET_STATE: CustomWidget[] = R.pipe(
  WIDGET_LAYOUTS,
  R.keys,
  R.map((key) => ({ indentifier: key as WidgetIdentifiers }))
);

// ...
Enter fullscreen mode Exit fullscreen mode

Now talking about the component itself, let's use the useLocalStorage() hook to store the widgets and layout state, as well as define the localstorage key so that these same data are persisted.

// @/src/App.tsx

// ...

export const App = () => {
  const [layout, setLayout] = useLocalStorage<Layout[] | undefined>(
    StorageKeys.LAYOUT,
    undefined
  );
  const [widgets, setWidgets] = useLocalStorage<CustomWidget[]>(
    StorageKeys.WIDGETS,
    DEFAULT_WIDGET_STATE
  );

  // ...
};
Enter fullscreen mode Exit fullscreen mode

One of the important points now is to bootstrap the layout, that is, if we still don't have a layout in place, we'll create a new one, otherwise we'll use the existing one. This way:

// @/src/App.tsx

// ...

export const App = () => {
  const [layout, setLayout] = useLocalStorage<Layout[] | undefined>(
    StorageKeys.LAYOUT,
    undefined
  );
  const [widgets, setWidgets] = useLocalStorage<CustomWidget[]>(
    StorageKeys.WIDGETS,
    DEFAULT_WIDGET_STATE
  );

  useEffect(() => {
    if (layout) return;
    boostrapLayout();
  }, []);

  const boostrapLayout = useCallback(() => {
    const initial = R.map(Object.entries(WIDGET_LAYOUTS), ([key, values]) => ({
      i: key,
      ...values[Object.keys(values).shift() as LayoutSizes],
    }));
    setLayout(initial as Layout[]);
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Now we need to create a function that will be responsible for updating the state of our layout whenever a widget is dragged to another location.

// @/src/App.tsx

// ...

export const App = () => {
  // ...

  const onLayoutChange = useCallback((newLayout: Layout[]) => {
    setLayout(newLayout);
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Since we have the function responsible for updating the layout, we also need to create a function that will be responsible for updating the state of the widgets.

This function will have two arguments, the widget identifier and the new size. And whenever this function is invoked we will update the widget state and it's grid layout values.

// @/src/App.tsx

// ...

export const App = () => {
  // ...

  const onLayoutChange = useCallback((newLayout: Layout[]) => {
    setLayout(newLayout);
  }, []);

  const onWidgetUpdate = useCallback(
    (identifier: WidgetIdentifiers, size: LayoutSizes) => {
      const layoutDeepCopy = R.clone(layout) ?? [];

      const updatedLayout = R.map(layoutDeepCopy, (elm) => {
        if (elm.i === identifier) {
          const { w, h } = WIDGET_LAYOUTS[identifier][size] as Layout;
          return { ...elm, w, h };
        }
        return elm;
      });

      const widgetsDeepCopy = R.clone(widgets) ?? [];

      const updatedWidgets = R.map(widgetsDeepCopy, (elm) => {
        if (elm.indentifier === identifier) {
          return { ...elm, ...(size ? { size } : {}) };
        }
        return elm;
      });

      setWidgets(updatedWidgets);
      setLayout(updatedLayout);
    },
    [layout, widgets]
  );

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Last but not least let's add the widgets to the dom and pass the respective props to each of them.

// @/src/App.tsx

// ...

export const App = () => {
  // ...

  const earningsProps = useMemo(
    () =>
      R.find(
        widgets,
        ({ indentifier }) => indentifier === WidgetIdentifiers.EARNINGS
      ),
    [widgets]
  );

  return (
    <GridLayout
      className="layout"
      layout={layout}
      cols={12}
      margin={[20, 20]}
      rowHeight={100}
      width={1200}
      onLayoutChange={onLayoutChange}
    >
      <div key={WidgetIdentifiers.PURCHASE}>
        <NormalWidget title="Purchase" />
      </div>

      <div key={WidgetIdentifiers.TRANSACTION}>
        <NormalWidget title="Transaction" />
      </div>

      <div key={WidgetIdentifiers.EARNINGS}>
        <ResizableWidget
          title="Earnings"
          identifier={WidgetIdentifiers.EARNINGS}
          onUpdate={onWidgetUpdate}
          size={earningsProps?.size}
        />
      </div>
    </GridLayout>
  );
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

As usual, I hope you enjoyed the article and that it helped you with an existing project or simply wanted to try it out.

If you found a mistake in the article, please let me know in the comments so I can correct it. Before finishing, if you want to access the source code of this article, I leave here the link to the github repository.

Top comments (0)