Build Chakra UI Image component using react, typescript, styled-components and styled-system
Tags: react, tutorial
Published: 2021-09-05 16:26:34

### Introduction

Let us continue building our chakra components using `styled-components` & `styled-system`. In this tutorial we will be cloning the Chakra UI `Image` component.

- I would like you to first check the chakra docs for [image](https://chakra-ui.com/docs/media-and-icons/image).
- All the code for this tutorial can be found under the **atom-image** branch [here](https://github.com/yaldram/chakra-ui-clone).

### Prerequisite

Please check the Chakra Image Component code [here](https://github.com/chakra-ui/chakra-ui/tree/main/packages/image/src). In this tutorial we will -

- Create an `useImage` hook.
- Create an `Image`component.
- Create story for the `Image` component.

### Setup

- First let us create a branch, from the main branch run -

```bash
git checkout -b atom-image
```

- Under the `components/atom` folder create an new folder called `image`. Under `image` folder create 3 files - `image.tsx`, `use-image.ts` and `index.ts`.

- So our folder structure stands like - **src/components/atoms/image**.

### useImage hook

- I would again request you to please check the chakra docs. The Image component takes in a lot of very [useful props](https://chakra-ui.com/docs/media-and-icons/image#props).

- Internally the Image component uses the useImage hook, this hook does some neat tricks. And the benefit of separating the image handling logic into it's own hook is that we can use this hook in other components like `Avatar`.

- First open the `utils/dom.ts` file and paste the following code -

```ts
export function canUseDOM(): boolean {
  return !!(
    typeof window !== "undefined" &&
    window.document &&
    window.document.createElement
  );
}

export const isBrowser = canUseDOM();
```

- Now under the src folder create a new folder called hooks. Under `src/hooks` create 2 files `use-safe-layout-effect.ts` & `index.ts`.

- useSafeLayoutEffect enables us to safely call `useLayoutEffect` on the browser (for SSR reasons). React currently throws a warning when using useLayoutEffect on the server. To get around it, we can conditionally useEffect on the server (no-op) and useLayoutEffect in the browser. Under `use-safe-layout-effect.ts` paste the following code -

```ts
import * as React from "react";

import { isBrowser } from "../utils";

export const useSafeLayoutEffect = isBrowser
  ? React.useLayoutEffect
  : React.useEffect;
```

- And under `hooks/index.ts` paste the following code -

```ts
export * from "./use-safe-layout-effect";
```

- Now let us start with the useImage hook let me first paste the code for you under `atoms/image/use-image.ts` -

```ts
import * as React from "react";

import { useSafeLayoutEffect } from "../../../hooks";

type Status = "loading" | "failed" | "pending" | "loaded";

type ImageEvent = React.SyntheticEvent<HTMLImageElement, Event>;

export interface UseImageProps {
  src?: string;
  srcSet?: string;
  sizes?: string;
  onLoad?(event: ImageEvent): void;
  onError?(error: string | ImageEvent): void;
  ignoreFallback?: boolean;
  crossOrigin?: React.ImgHTMLAttributes<any>["crossOrigin"];
}

export function useImage(props: UseImageProps) {
  const { src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback } =
    props;

  const imageRef = React.useRef<HTMLImageElement | null>();

  const [status, setStatus] = React.useState<Status>("pending");

  React.useEffect(() => {
    setStatus(src ? "loading" : "pending");
  }, [src]);

  const flush = () => {
    if (imageRef.current) {
      imageRef.current.onload = null;
      imageRef.current.onerror = null;
      imageRef.current = null;
    }
  };

  const load = React.useCallback(() => {
    if (!src) return;

    flush();

    const img = new Image();

    img.src = src;

    if (crossOrigin) {
      img.crossOrigin = crossOrigin;
    }

    if (srcSet) {
      img.srcset = srcSet;
    }

    if (sizes) {
      img.sizes = sizes;
    }

    img.onload = (event) => {
      flush();
      setStatus("loaded");
      onLoad?.(event as unknown as ImageEvent);
    };

    img.onerror = (error) => {
      flush();
      setStatus("failed");
      onError?.(error as any);
    };

    imageRef.current = img;
  }, [src, crossOrigin, srcSet, sizes, onLoad, onError]);

  useSafeLayoutEffect(() => {
    if (ignoreFallback) return undefined;

    if (status === "loading") {
      load();
    }

    return () => {
      flush();
    };
  }, [status, load, ignoreFallback]);

  return ignoreFallback ? "loaded" : status;
}

export type UseImageReturn = ReturnType<typeof useImage>;
```

- The basic use of `useImage` hook is to return the status of our image, whether it is loading or loaded.

- We can also pass some cool `onError` & `onLoad` callback functions as props to the `Image` component to handle those scenarios the `useImage` hook takes care of calling these.

- More on `useImage` later, let us use it in the Image component.

### Image Component

- Under the `utils/objects.ts` file paste the following code -

```ts
export function omit<T extends Dict, K extends keyof T>(object: T, keys: K[]) {
  const result: Dict = {};

  Object.keys(object).forEach((key) => {
    if (keys.includes(key as K)) return;
    result[key] = object[key];
  });

  return result as Omit<T, K>;
}
```

- Under the folder `atoms/image/image.tsx` paste the following code -

```tsx
import * as React from "react";
import styled from "styled-components";
import { system, BorderRadiusProps, LayoutProps } from "styled-system";

import { omit } from "../../../utils";
import { useImage, UseImageProps } from "./use-image";

interface ImageOptions {
  fallbackSrc?: string;
  fallback?: React.ReactElement;
  loading?: "eager" | "lazy";
  fit?: React.CSSProperties["objectFit"];
  align?: React.CSSProperties["objectPosition"];
  ignoreFallback?: boolean;
  boxSize?: LayoutProps["size"];
  borderRadius?: BorderRadiusProps["borderRadius"];
}

export interface ImageProps
  extends UseImageProps,
    ImageOptions,
    Omit<React.ComponentPropsWithoutRef<"img">, keyof UseImageProps> {}

const BaseImage = styled.img`
  ${system({
    boxSize: {
      properties: ["width", "height"],
    },
    borderRadius: {
      property: "borderRadius",
    },
  })}
`;

export const Image = React.forwardRef<HTMLImageElement, ImageProps>(
  (props, ref) => {
    const {
      fallbackSrc,
      fallback,
      src,
      align,
      fit,
      loading,
      ignoreFallback,
      crossOrigin,
      alt,
      ...delegated
    } = props;

    const shouldIgnore = loading != null || ignoreFallback;

    const status = useImage({
      ...props,
      ignoreFallback: shouldIgnore,
    });

    const shared = {
      objectFit: fit,
      objectPosition: align,
      ...(shouldIgnore ? delegated : omit(delegated, ["onError", "onLoad"])),
    };

    if (status !== "loaded") {
      if (fallback) return fallback;

      return <BaseImage ref={ref} src={fallbackSrc} alt={alt} {...shared} />;
    }

    return (
      <BaseImage
        ref={ref}
        src={src}
        alt={alt}
        crossOrigin={crossOrigin}
        loading={loading}
        {...shared}
      />
    );
  }
);
```

- First things to notice is that the `Image` component takes in `boxSize` & `borderRadius` props so we added these to the system().

- There are 2 separate props namely `fallback` which is a React component and `fallbackSrc`, if the status != "loaded" we return the `fallback` if passed or a placeholder instead, whose src is passed using the `fallbackSrc` prop.

- If we pass either the `loading` prop or `ignoreFallback` prop we ignoreFallback and `useImage` hook will return 'loaded' meaning we won't show any fallback.

- Guys I know I am doing a pretty bad job of explaining this, but again try passing these props and write some console logs in the code you will understand it better.

### Story

- With the above our `Image` component is completed, let us create a story.
- Under the `src/components/atoms/image/image.stories.tsx` file we add the below story code -

```tsx
import * as React from "react";

import { Flex, Stack } from "../layout";
import { Image } from "./image";

export default {
  title: "Atoms/Image",
};

export const Default = {
  render: () => (
    <Stack direction="row" spacing="3xl">
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        ignoreFallback
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallbackSrc="https://via.placeholder.com/150"
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallback={
          <Flex
            bg="orange500"
            align="center"
            justify="center"
            color="white"
            size="150px"
            borderRadius="9999px"
          >
            Loading...
          </Flex>
        }
        fit="cover"
        alt="Segun Adebayo"
      />
    </Stack>
  ),
};
```

- Now run `npm run storybook` check the stories. Try changing your browser net speed to slow 3G and check the fallback for the second and third image. For the first image it won't show anything as fallback because we passed in ignoreFallback.

### Build the Library

- Under the `image/index.ts` file paste the following -

```ts
export * from "./image";
export * from "./use-image";
```

- Under the `/atom/index.ts` file paste the following -

```ts
export * from "./layout";
export * from "./typography";
export * from "./feedback";
export * from "./icon";
export * from "./icons";
export * from "./form";
export * from "./image";
```

- Now `npm run build`.

- Under the folder `example/src/App.tsx` we can test our `Image` component. Copy paste the following code and run `npm run start` from the `example` directory. Be sure to set network speed of your browser to slow 3G.

```tsx
import * as React from "react";
import { Flex, Stack, Image } from "chakra-ui-clone";

export function App() {
  return (
    <Stack m="lg" direction="row" spacing="3xl">
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        ignoreFallback
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallbackSrc="https://via.placeholder.com/150"
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sa-adebayo"
        fallbackSrc="https://via.placeholder.com/150"
        fit="cover"
        alt="Segun Adebayo"
        onError={() => alert("File Failed to Load")}
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallback={
          <Flex
            bg="orange500"
            align="center"
            justify="center"
            color="white"
            size="150px"
            borderRadius="9999px"
          >
            Loading...
          </Flex>
        }
        fit="cover"
        alt="Segun Adebayo"
      />
    </Stack>
  );
}
```

### Summary

There you go guys in this tutorial we created `Image` component just like chakra ui . You can find the code for this tutorial under the **atom-image** branch [here](https://github.com/yaldram/chakra-ui-clone). In the next tutorial we will create `Avatar` component. Until next time PEACE.