DEV Community

Cover image for Using Crystalize.js with React for dynamic state management
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Using Crystalize.js with React for dynamic state management

Written by Onuorah Bonaventure✏️

State management is a very relevant concept in modern app development, especially for single-page applications. It is used to control and monitor actions, user inputs, and responses from a server.

Developers often use popular libraries like Redux and MobX to manage state in React applications. However, these solutions mostly provide a way to access data globally without having to pass props around components and pages.

Unfortunately, these libraries don't provide a way to undo or redo actions in an application, or to navigate to a particular point in a series of actions. This is where the Crystalize.js library comes in.

In this article, we'll explore the Crystalize.js library and how to use it with React by building a photo editing web app with redo, undo, and download functionalities. We'll also look at the similarities and differences it shares with Redux.

Jump ahead:

You can view the source code for our demo project on GitHub. By the end of this tutorial, we'll have an app that works as shown in the video below: https://www.youtube.com/watch?v=At-9Da_Oba8

What is Crystalize.js?

Crystalize.js is a library specifically designed to manage state dynamically. It captures actions and data as “shards” and stores them in a state known as a “crystal.” But what does this all mean?

Interestingly, because of the way the Crystalize.js library handles data, we can do things we can't do with other reducers and state management solutions. For example, we can implement undo and redo functionality in an application.

Because of this new approach to state management, Crystalize.js introduces a bunch of new terms such as crystalizer, crystal, shards, and base. It also provides a set of APIs including take, leave, with, focus, and more.

Rather than defining these terms upfront, we’ll discuss them in detail while working on our demo project in the subsequent sections. This will give us a more practical understanding of this library and how it works.

Setting up a React application with Crystalize.js

In order to take a deep dive into Crystalize.js, we will set up a React app using the Next.js CLI.

We'll start by creating a new folder for our project. You can name yours whatever you'd like. For this tutorial, I'll call the folder dynamically-manage-state-with-crystalize-js.

Next, open the folder in a code editor such as VS Code. In your terminal, run the following command:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Follow the prompts to set up your project. Once it’s set up, install the Crystalize.js library by running the following command:

npm i crystalize.js
Enter fullscreen mode Exit fullscreen mode

You’ll also need to install helper libraries for converting an HTML file to an image, downloading the converted image, and getting icons. You can do so by running the following command:

npm i html-to-image downloadjs react-icons
Enter fullscreen mode Exit fullscreen mode

Then, create a components and crystalizer folders in the src directory. Additionally, create the following jsx files in the components folder:

  • canvas.jsx
  • download-button.jsx
  • edit-tool-list.jsx
  • editor.jsx
  • image-uploader.jsx
  • range-input-field.jsx
  • redo-button.jsx
  • select-field.jsx
  • sidebar.jsx
  • text-field.jsx
  • tool-list-item.jsx
  • undo-button.jsx

Lastly, create a state.jsx in the crystalizer folder. At this point, your project folder structure should look similar to this:

// Folder structure 

┣ public
 ┃ ┣ favicon.ico
 ┃ ┣ next.svg
 ┃ ┗ vercel.svg
 ┣ src
 ┃ ┣ components
 ┃ ┃ ┣ canvas.jsx
 ┃ ┃ ┣ download-button.jsx
 ┃ ┃ ┣ edit-tool-list.jsx
 ┃ ┃ ┣ editor.jsx
 ┃ ┃ ┣ image-uploader.jsx
 ┃ ┃ ┣ range-input-field.jsx
 ┃ ┃ ┣ redo-button.jsx
 ┃ ┃ ┣ select-field.jsx
 ┃ ┃ ┣ sidebar.jsx
 ┃ ┃ ┣ text-field.jsx
 ┃ ┃ ┣ tool-list-item.jsx
 ┃ ┃ ┗ undo-button.jsx
 ┃ ┣ crystalizer
 ┃ ┃ ┗ state.jsx
 ┃ ┣ pages
 ┃ ┃ ┣ api
 ┃ ┃ ┃ ┗ hello.js
 ┃ ┃ ┣ _app.jsx
 ┃ ┃ ┣ _document.jsx
 ┃ ┃ ┗ index.jsx
 ┃ ┗ styles
 ┃ ┃ ┣ Home.module.css
 ┃ ┃ ┗ globals.css
 ┣ .gitignore
 ┣ README.md
 ┣ jsconfig.json
 ┣ next-env.d.ts
 ┣ next.config.js
 ┣ package-lock.json
 ┣ package.json
 ┗ tsconfig.json
Enter fullscreen mode Exit fullscreen mode

After setting up the folders, we will open the src/crystalizer/state.jsx file and start exploring the Crystalize.js library.

Setting up the Context API

Since Crystalize.js doesn’t provide global state management, we’ll set it up using the React Context API. To do so, write the following code in the src/crystalizer/state.jsx file:

import { createContext, useContext } from 'react';

// Initialize context
const EditImageContext = createContext(null);

// Context provider
export const EditImageContextProvider = ({ children }) => {
  return (
    <EditImageContext.Provider
      value={{}}
    >
      {children}
    </EditImageContext.Provider>
  );
};

// Hook to access the data in the context
export const useEditImage = () => {
  const context = useContext(EditImageContext);
  if (!context) {
    throw new Error('Use edit image context is missing');
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

Now we have the Context API set up, but we still need to wrap the part of the application where we want to share the context with the Provider — in our case, the Home page. This should look similar to the code below:

// src/pages/index.js

import React from 'react';
import { EditImageContextProvider } from '../crystalizer/state';

const Home = (props) => {
  return (
    <EditImageContextProvider>
       {/** Other components will go here **/}
    </EditImageContextProvider>
  );
};
export default Home;
Enter fullscreen mode Exit fullscreen mode

We’re ready to use Crystalize.js in our React project now. Let’s get started.

Initializing Crystalize.js

To begin using Crystalize.js in our React project, let’s import the library in the src/crystalizer/state.jsx file and call the Crystalizer class with the appropriate arguments. It’s worth noting that the initialData object will contain id and style keys:

// src/crystalizer/state.jsx

import { createContext, useContext,  useRef, useCallback, useState, useMemo } from 'react';
import Crystalizer from 'crystalize.js';
import { toPng } from 'html-to-image';
import download from 'downloadjs';

// Initialize context
const EditImageContext = createContext(null);

export const initialData = {
  style: {
    width: 400, // px
    height: 400, // px
    blur: 0, // px
    brightness: 100, // %
    contrast: 100, // %
    grayscale: 0, // %
    'hue-rotate': 0, // deg
    invert: 0, // %
    opacity: 100, // %
    sepia: 0, // %
    scale: 1, // number
    'object-fit': 'none', // fill, contain, cover, none, scale-down
  },
  id: 1,
};

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    return { id: shard.id + crystal.id, style: shard.style };
  },
});

// Context provider
//...
Enter fullscreen mode Exit fullscreen mode

Let's break down the code above piece by piece.

After importing everything we needed and setting up some styles for our initialData, we created a crystalizerInitializer variable that creates a new Crystalizer.

The Crystalizer class accepts two mandatory keys: initial and reduce. It can also accept additional optional keys including map, sort, tskey, and keep.

Generally, you should pass an object containing at least one unique value as the initialData. This helps structure your crystals properly and store your shard easily. As shown above, the initiaData was passed to the initial key.

Meanwhile, a callback with two parameters was passed to the reduce key. The crystal parameter represents the initial data passed to the Crystalizer class, while the shard represents the current item that’s passed to the crystalizer engine from our application at any point in time.

We expect the callback in the reduce key to return the values that will be added to the app’s main crystal.

Setting up states

Since we are building a photo editing app with redo and undo features, we need to define an imageRef and four states. We’ll also set up a fifth state to properly extract the content of the crystalizer in the context provider. Here’s how we’ll do it:

// src/crystalizer/state.jsx

  const imageRef = useRef();

  const [imageUrl, setImageUrl] = useState('');
  const [crystalizer, setCrystalizer] = useState(crystalizerInitializer);
  const [modifier, setModifier] = useState('width');
  const [pointer, setPointer] = useState(0)

  const [crystal, shards, base] = crystalizer.leave(pointer).take(1);
Enter fullscreen mode Exit fullscreen mode

Let’s decode what’s happening here:

  • The imageRef object grabs the container of the image and allows it to be downloaded
  • The imageUrl state holds the URL of the locally uploaded image
  • The crystalizer state holds the crystalizerInitializer and any subsequent updates to the crystalizer
  • The modifier state holds the aspect of the image style that the user chooses to modify — in the code above, the width
  • The pointer state holds a value we can pass to the .leave method to determine how many shards we remove from the crystalizer — in the code above, 0

Lastly, the fifth state returns crystal, shards, and base variables by calling the crystalizer.leave(N).take(N) method. The .leave method excludes a certain number of shards from a result, and the .take method selects a certain number of shards from the crystalizer.

Let’s go through an example to understand this a little better. Think of the crystalizer as an array containing a series of objects. The .leave method allows us to remove a number of objects from the array starting from the last added item. The .take method allows us to choose specific ones.

If we had ten shards in a crystal — in other words, ten objects in an array — we could call the .leave method and pass three as an argument, leaving us with seven. If we passed 1 to the .take method, it will return the last .shard from those available in the crystal.

In our case, we passed a pointer state of 0 to the .leave method. We also passed 1 to the .take method. This means we’re excluding zero shards from the crystal and picking the last one from the available list.

Therefore, suppose we have this list in our crystalizer:

[
  {
    style: {
      width: 400,
      height: 400,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 1,
  },
  {
    style: {
      width: 500,
      height: 500,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 2,
  },
  {
    style: {
      width: 600,
      height: 600,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 3,
  },
  {
    style: {
      width: 700,
      height: 700,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 4,
  },
  {
    style: {
      width: 800,
      height: 800,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 5,
  },
  {
    style: {
      width: 900,
      height: 900,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 6,
  },
  {
    style: {
      width: 1000,
      height: 1000,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 7,
  },
  {
    style: {
      width: 1100,
      height: 1100,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 8,
  },
  {
    style: {
      width: 1200,
      height: 1200,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 9,
  },
  {
    style: {
      width: 1300,
      height: 1300,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 10,
  },
]
Enter fullscreen mode Exit fullscreen mode

This gives us the following result:

const [pointer, setPointer] = useState(0)
const [crystal, shards, base] = crystalizer.leave(pointer).take(1);

console.log(shards)

/**
// This is the value in the shard.
// Notice the id
  [{
    style: {
      width: 1300,
      height: 1300,
      blur: 0,
      brightness: 100,
      contrast: 100,
      grayscale: 0,
      'hue-rotate': 0,
      invert: 0,
      opacity: 100,
      sepia: 0,
      scale: 1,
      'object-fit': 'none',
    },
    id: 10,
  }]
 */
Enter fullscreen mode Exit fullscreen mode

The crystal returned is the result of combining the shards, which we likely won’t need to use. We’re more interested in the shards array, which represents the current result or state. Lastly, the base will return a summary of all the shards that were ignored.

Besides removing or picking shards using the .take and .leave methods, we can also use the .with method to add new shards to the crystalizer. Here’s an example:

crystalizer.with(
{ 
  id: 1234, 
  style: {
    width: 768, 
    height: 534,
    blur: 0,
    brightness: 100,
    contrast: 100,
    grayscale: 0,
    'hue-rotate': 0,
    invert: 0,
    opacity: 100,
    sepia: 0,
    scale: 1,
    'object-fit': 'none',
  },
 })
Enter fullscreen mode Exit fullscreen mode

This will update the crystalizer to contain a new object field with an id of 1234 and the style in the code above.

Setting up helper functions

In this section, we will set up callback functions for uploading images, undoing actions, redoing actions, and downloading the edited image. Just below the states we defined in the previous section, we will add the following code:

// src/crystalizer/state.jsx

const onImageUpload = useCallback((image) => {
    const _image = URL.createObjectURL(image);
    setImageUrl(_image);
}, []);

const handleUndo = useCallback(() => {
    setPointer((l) => l + 1);
}, []);

const handleRedo = useCallback(() => {
    setPointer((l) => l - 1);
}, []);

const downloadImage = useCallback(() => {
    toPng(imageRef.current)
      .then((dataURL) => {
        download(dataURL, 'custom-image.png');
      })
      .catch((e) => {
        console.log({ e });
      });
}, []);
Enter fullscreen mode Exit fullscreen mode

In the code above, the onImageUpload callback accepts an image parameter — which must be a blob — converts it into a URL, and adds it to the imageUrl state.

The handleUndo callback simply takes the current pointer state and increments it by one. Similarly, the handleRedo callback takes the current pointer state and decrements it by one.

Meanwhile, the downloadImage callback implements the toPng method from the html-to-png library with the download method from the downloadjs library. This allows the user to download the edited image as a PNG file.

Setting up the imageStyle memo

In this section, we’ll set up the imageStyle memo in the same src/crystalizer/state.jsx folder that we’ve been working in.

imageStyle is a memo that computes and returns an object that matches the initialData we previously defined. We do this by predefining the variables and then updating them based on the values of the shards.

Generally, there are no shards available at first — just the crystal, which holds the initialData. When we check for the value of the shards and this value equals 0, we can use the values from the crystal to update each of the style keys. Otherwise, we will use the first item in the shards:

const imageStyle = useMemo(() => {
    let width, height, objectFit, opacity, blur, brightness, contrast, grayscale, 
    invert, sepia, scale, hueRotate;
    if (shards.length === 0) {
      width = crystal?.style?.width;
      height = crystal?.style?.height;
      objectFit = crystal?.style['object-fit'];
      opacity = crystal?.style['opacity'];
      blur = crystal?.style['blur'];
      brightness = crystal?.style['brightness'];
      contrast = crystal?.style['contrast'];
      grayscale = crystal?.style['grayscale'];
      invert = crystal?.style['invert'];
      sepia = crystal?.style['sepia'];
      scale = crystal?.style['scale'];
      hueRotate = crystal?.style['hue-rotate'];
  } else {
      width = shards[0]?.style.width;
      height = shards[0]?.style.height;
      objectFit = shards[0]?.style['object-fit'];
      opacity = shards[0]?.style['opacity'];
      blur = shards[0]?.style['blur'];
      brightness = shards[0]?.style['brightness'];
      contrast = shards[0]?.style['contrast'];
      grayscale = shards[0]?.style['grayscale'];
      invert = shards[0]?.style['invert'];
      sepia = shards[0]?.style['sepia'];
      scale = shards[0]?.style['scale'];
      hueRotate = shards[0]?.style['hue-rotate'];
    }
Enter fullscreen mode Exit fullscreen mode

Lastly, we need to return two objects. The first object is the style key, which we can pass to the style of an element. The other object contains the plain values, which we can use to visually show the current value of a style:

    return {
      style: {
        width: `${width}px`,
        height: `${height}px`,
        objectFit,
        filter: `opacity(${opacity}%) blur(${blur}px) brightness(${brightness}%) brightness(${brightness}%) contrast(${contrast}%) grayscale(${grayscale}%) invert(${invert}%) sepia(${sepia}%) hue-rotate(${hueRotate}deg)`,
        transform: `scale(${scale})`,
      },
      plain: {
        width,
        height,
        objectFit,
        opacity,
        blur,
        brightness,
        contrast,
        grayscale,
        invert,
        sepia,
        scale,
        hueRotate,
      },
    };
  }, [crystal]);
Enter fullscreen mode Exit fullscreen mode

Now that we have defined our functions, state, and values, we can then pass them to the provider like so:

// src/crystalizer/state.jsx

  value={{
        crystalizer,
        setCrystalizer,
        imageUrl,
        onImageUpload,
        modifier,
        setModifier,
        imageStyle,
        shard: shards[0] ?? base,
        handleUndo,
        handleRedo,
        setPointer,
        pointer,
        imageRef,
        downloadImage,
      }}
Enter fullscreen mode Exit fullscreen mode

Note that the value of the shard should be the first element from the shards array. If there are no shards, we will use the value of the base.

At this point, our complete src/crystalizer/state.jsx file should look similar to this:

import { createContext, useRef, useCallback, useContext, useState, useMemo } from 'react';
import Crystalizer from 'crystalize.js';
import { toPng } from 'html-to-image';
import download from 'downloadjs';

const EditImageContext = createContext(null);
export const initialData = {
  style: {
    width: 400, // px
    height: 400, // px
    blur: 0, // px
    brightness: 100, // %
    contrast: 100, // %
    grayscale: 0, // %
    'hue-rotate': 0, // accepts angle / 360deg
    invert: 0, // %
    opacity: 100, // %
    sepia: 0, // %
    scale: 1, // number
    'object-fit': 'none', // fill, containe, cover, none, scale-down
  },
  id: 1,
};

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    console.log({ crystal, shard });
    return { id: shard.id + crystal.id, style: shard.style };
  },
});

// main context provider
export const EditImageContextProvider = ({ children }) => {
  const imageRef = useRef();
  const [imageUrl, setImageUrl] = useState('');
  const [crystalizer, setCrystalizer] = useState(crystalizerInitializer);
  const [modifier, setModifier] = useState('width');
  const [pointer, setPointer] = useState(0);

  const [crystal, shards, base] = crystalizer.leave(pointer).take(1);

  const onImageUpload = useCallback((image) => {
    const _image = URL.createObjectURL(image);
    setImageUrl(_image);
  }, []);

  const handleUndo = useCallback(() => {
    setPointer((l) => l + 1);
  }, []);

  const handleRedo = useCallback(() => {
    setPointer((l) => l - 1);
  }, []);

  const downloadImage = useCallback(() => {
    toPng(imageRef.current)
      .then((dataURL) => {
        download(dataURL, 'custom-image.png');
      })
      .catch((e) => {
        console.log({ e });
      });
  }, []);

  const imageStyle = useMemo(() => {
    let width, height, objectFit, opacity, blur, brightness, contrast, grayscale, invert, sepia, scale, hueRotate;
    if (shards.length === 0) {
      width = crystal?.style?.width;
      height = crystal?.style?.height;
      objectFit = crystal?.style['object-fit'];
      opacity = crystal?.style['opacity'];
      blur = crystal?.style['blur'];
      brightness = crystal?.style['brightness'];
      contrast = crystal?.style['contrast'];
      grayscale = crystal?.style['grayscale'];
      invert = crystal?.style['invert'];
      sepia = crystal?.style['sepia'];
      scale = crystal?.style['scale'];
      hueRotate = crystal?.style['hue-rotate'];
    } else {
      width = shards[0]?.style.width;
      height = shards[0]?.style.height;
      objectFit = shards[0]?.style['object-fit'];
      opacity = shards[0]?.style['opacity'];
      blur = shards[0]?.style['blur'];
      brightness = shards[0]?.style['brightness'];
      contrast = shards[0]?.style['contrast'];
      grayscale = shards[0]?.style['grayscale'];
      invert = shards[0]?.style['invert'];
      sepia = shards[0]?.style['sepia'];
      scale = shards[0]?.style['scale'];
      hueRotate = shards[0]?.style['hue-rotate'];
    }

    return {
      style: {
        width: `${width}px`,
        height: `${height}px`,
        objectFit,
        filter: `opacity(${opacity}%) blur(${blur}px) brightness(${brightness}%) brightness(${brightness}%) contrast(${contrast}%) grayscale(${grayscale}%) invert(${invert}%) sepia(${sepia}%) hue-rotate(${hueRotate}deg)`,
        transform: `scale(${scale})`,
      },
      plain: {
        width,
        height,
        objectFit,
        opacity,
        blur,
        brightness,
        contrast,
        grayscale,
        invert,
        sepia,
        scale,
        hueRotate,
      },
    };
  }, [crystal]);

  return (
    <EditImageContext.Provider
      value={{
        crystalizer,
        setCrystalizer,
        imageUrl,
        onImageUpload,
        modifier,
        setModifier,
        imageStyle,
        shard: shards[0] ?? base,
        handleUndo,
        handleRedo,
        setPointer,
        pointer,
        imageRef,
        downloadImage,
      }}
    >
      {children}
    </EditImageContext.Provider>
  );
};

export const useEditImage = () => {
  const context = useContext(EditImageContext);
  if (!context) {
    throw new Error('Use edit image context is missing');
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

With that, we’ve finished setting up all the logic for our React photo editing app. With the Crystalize.js library, we can dynamically manage state, allowing the user to upload and edit images, undo and redo actions, and download the edited image as a PNG file.

Now that the logic is set up, let’s move on to styling the app and its UI.

Styling the app and creating UI components

Since our main focus for this article is the Crystalize.js library, I have set up some CSS styles we can use. Open the styles/globals.css file and add the following code:

:root {
  --primary: #007bff;
  --info: #17a2b8;
  --success: #28a745;
  --dark: #343a40;
  --danger: #dc3545;
  --warning: #ffc107;
  --secondary: #6c757d;
  --light: #f8f9fa;
  --white: #ffffff;
}
* {
  box-sizing: border-box;
}
html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
}
.app-wrapper {
  display: flex;
  width: 100%;
  height: 100%;
  justify-content: center;
}
.sidebar-wrapper {
  width: 200px;
  min-height: 100vh;
  border-right: 2px solid var(--dark);
  overflow-y: auto;
  background-color: var(--dark);
  color: var(--light);
}
.image-uploader-wrapper {
  height: 100px;
  display: flex;
  justify-content: center;
  align-items: center;
}
#image-selector {
  display: none;
  border: 2px solid blue;
}
.image-uploader-wrapper .image-uploader-label {
  display: flex;
  gap: 1rem;
  align-items: center;
  justify-content: center;
  text-transform: capitalize;
  background-color: var(--warning);
  color: var(--dark);
  padding: 0.5rem 1rem;
  border-radius: 10px;
  cursor: pointer;
}
.tool-list-wrapper {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  align-items: center;
}
.tool-list-wrapper .tool-list-item {
  cursor: pointer;
  text-transform: capitalize;
  width: 150px;
  padding: 0.5rem 0.6rem;
  font-size: 0.9rem;
}
.tool-list-header {
  text-align: center;
  margin: 0;
  margin-bottom: 1rem;
  font-weight: 500;
  font-size: 1.2rem;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
    'Helvetica Neue', sans-serif;
  letter-spacing: 1px;
}
.tool-list-wrapper .tool-list-item:hover {
  filter: brightness(0.95);
  transform: scaleX(1.05);
}
.tool-list-wrapper .tool-list-item span {
  display: block;
  text-align: left;
  font-family: monospace;
}
.canvas-editor-wrapper {
  min-height: 100vh;
  flex: 1;
  display: flex;
}
.canvas-wrapper {
  width: 100%;
  flex: 1;
  background-color: var(--light);
  overflow: auto;
}
.canvas-wrapper .image-container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
.canvas-wrapper img {
  transition: all 0.5s linear;
}
.canvas-wrapper .no-image {
  font-size: 3rem;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  color: var(--secondary);
}
.editor-wrapper {
  width: 300px;
  background-color: var(--dark);
  padding: 1rem;
}
.editor-wrapper > .buttons {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-direction: column;
}
.editor-wrapper .action-buttons {
  display: flex;
  gap: 1rem;
  margin: 1rem auto;
}
.editor-items {
  background-color: var(--light);
  height: 300px;
  padding-top: 2rem;
}
.editor-items-header {
  text-align: center;
  text-transform: capitalize;
}
.editor-field {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1rem;
  text-transform: capitalize;
}
.editor-field input,
.editor-field select {
  font-size: 1rem;
  padding: 0.2rem;
}
.editor-field button {
  width: 100px;
  margin-left: auto;
  margin-top: 1rem;
  background-color: var(--primary);
  color: var(--light);
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 15px;
  cursor: pointer;
}
.undo-button,
.redo-button {
  padding: 0.2rem 1rem;
  display: flex;
  gap: 0.5rem;
  cursor: pointer;
}
.download-button-wrapper {
  display: flex;
  justify-content: flex-end;
  padding: 1rem;
}
.download-button-wrapper > .download-button {
  background-color: var(--primary);
  color: var(--white);
  border: none;
  padding: 0.8rem 1.4rem;
  border-radius: 25px;
  cursor: pointer;
  filter: brightness(0.95);
}
.download-button-wrapper > .download-button:hover {
  filter: brightness(1);
}
Enter fullscreen mode Exit fullscreen mode

You can edit these styles or style your React app however you want. With these styles set up, let’s create the different UI components we’ll need in our photo editing app.

Setting up the image uploader

Let’s create an image uploader component that allows us to select an image from our device. We’ll store the selected image in the imageUrl state by using the onImageUpload callback we set up earlier, which we have passed from the context.

Here’s the code:

// src/components/image-uploader.jsx

import React from 'react';
import { FaPlus } from 'react-icons/fa';
import { useEditImage } from '../crystalizer/state';

export const ImageUploader = (props) => {
  const { onImageUpload } = useEditImage();

  return (
    <div className='image-uploader-wrapper'>
      <label htmlFor='image-selector' className='image-uploader-label'>
        <span>Upload image</span>
        <FaPlus size={16} />
        <input type='file' accept='image/*' id='image-selector' onChange={(e) => onImageUpload(e.target.files[0])} />
      </label>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The result is a button labeled Upload Image + that uses the FaPlus icon from the React Icons library as a visual cue. We’ll set up a sidebar component later on and render this button there.

Setting up the ToolListItem component

The tool list item is a component that renders each style available to modify for the image, such as the height. It accepts an item prop and calls the setModifier method when the style item is clicked.

Generally, we will only allow the user to click any of the style buttons after uploading an image. Our code should look similar to this:

// src/components/tool-list-item.jsx

import React, { useCallback } from 'react';
import { useEditImage } from '../crystalizer/state';

export const ToolListItem = ({ item }) => {
  const { setModifier, imageUrl } = useEditImage();

  const handleClick = useCallback((value) => {
    setModifier(value);
  }, []);

  return (
    <button className='tool-list-item' onClick={() => handleClick(item)} disabled={!imageUrl}>
      <span>{item}</span>
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

In the UI, this ToolListItem component will be rendered as a button in the sidebar labeled with a style item. After uploading an image, the user can use this component by clicking on the style they want to edit.

Setting up the EditToolList component

The EditToolList will be rendered as a component that contains a h2 and a div. The div will essentially render a bunch of ToolListItem components based on the value of the initialData style value.

Here is the code:

// src/components/edit-tool-list.jsx

import React from 'react';
import { initialData } from '../crystalizer/state';
import { ToolListItem } from './tool-list-item';

export const EditToolList = (props) => {
  return (
    <div>
      <h2 className='tool-list-header'>Select Modifier</h2>
      <div className='tool-list-wrapper'>
        {Object.keys(initialData.style).map((x) => {
          return <ToolListItem item={x} key={x} />;
        })}
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Setting up the sidebar component

The sidebar component simply combines and renders the ImageUploader and EditToolList components in a div like so:

// src/components/tool-list-item.jsx

import React from 'react';
import { EditToolList } from './edit-tool-list';
import { ImageUploader } from './image-uploader';
export const Sidebar = (props) => {
  return (
    <aside className='sidebar-wrapper'>
      <ImageUploader />
      <EditToolList />
    </aside>
  );
};
Enter fullscreen mode Exit fullscreen mode

To view our changes, we will add the Sidebar to the pages/index.jsx file. Our code should look like this:

import React from 'react';
import { Sidebar } from '../components/sidebar';
import { EditImageContextProvider } from '../crystalizer/state';

const Home = (props) => {
  return (
    <EditImageContextProvider>
      <div className='app-wrapper'>
        <Sidebar />
      </div>
    </EditImageContextProvider>
  );
};
export default Home;
Enter fullscreen mode Exit fullscreen mode

At this moment, our application should look like this: Demo Crystalize Js App Showing Sidebar With Button To Upload Image And List Of Image Modifiers So far, we’ve set up a sidebar for our application where users can upload an image by pressing the button, after which they can select an aspect of the image to edit.

As you can see, the list of what’s available to edit is currently grayed out. This is because we are only allowing the user to select an option from this list after uploading an image. Once we upload an image, the options on the list will change to a lighter color and become clickable.

Setting up the canvas component

The canvas component will display the uploaded image to be edited. When we currently have not selected an image, we will display some text that says No Image Selected. Otherwise, we will show a div that wraps around an img element.

The div that wraps the image will accept the imageRef we defined in the crystalizer/state.jsx file, while the img element will accept the imageUrl in the src folder as well as the imageStyle from the useEditImage Hook in the context provider.

The code for the component will look like this:

// src/components/canvas.jsx

import React from 'react';
import { useEditImage } from '../crystalizer/state';

export const Canvas = (props) => {
  const { imageUrl, imageStyle, imageRef } = useEditImage();

  return (
    <div className='canvas-wrapper'>
      {imageUrl ? (
        <div className='image-container' ref={imageRef}>
          <img src={imageUrl} style={{ ...imageStyle.style }} />
        </div>
      ) : (
        <div className='no-image'>
          <span>No Image Selected</span>
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

When we haven't yet selected an image, our application will look similar to this: Demo Crystalize Js App Showing Sidebar With Blank Canvas Reading No Image Selected After uploading an image, the app will render the selected image in the canvas like so: Crystalize Js App With Sidebar And Canvas Now Containing Uploaded And Rendered Image Of A Small Dog In A Hoodie Notice that the buttons for the style modifiers are now enabled after uploading an image.

Setting up the download button

Let’s create a component that renders a button with the downloadImage callback passed to the onClick event handler. The code looks similar to this:

// src/components/download-button.jsx

import React from 'react';
import { useEditImage } from '../crystalizer/state';

export const DownloadButton = (props) => {
  const { downloadImage } = useEditImage();

  return (
    <div className='download-button-wrapper'>
      <button type='button' className='download-button' onClick={downloadImage}>
        Download image
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The result is a button labeled Download image that users can click to download the edited image as a PNG file.

Setting up the undo and redo buttons

The undo button component renders a button with an undo icon and calls the handleUndo callback defined in the context provider. The code should look similar to this:

// src/components/undo-button.jsx

import React from 'react';
import { FaUndo } from 'react-icons/fa';
import { useEditImage } from '../crystalizer/state';

export const UndoButton = () => {
  const { handleUndo } = useEditImage();

  return (
    <div>
      <button type='button' className='undo-button' onClick={handleUndo}>
        <span>
          <FaUndo />
        </span>
        <span>Undo</span>
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The redo button is similar to the undo button component. It implements the handleRedo callback and renders a redo icon like so:

// src/components/redo-button.jsx

import React from 'react';
import { FaRedo } from 'react-icons/fa';
import { useEditImage } from '../crystalizer/state';

export const RedoButton = () => {
  const { handleRedo } = useEditImage();

  return (
    <div>
      <button type='button' className='redo-button' onClick={handleRedo}>
        <span>
          <FaRedo />
        </span>
        <span>Redo</span>
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Remember, the undo and redo functionality is made possible thanks to Crystalize.js. These components simply call the respective callbacks that we defined earlier in the state.jsx file.

Setting up the text field component

We need to provide various methods for editing different aspects of the uploaded image, depending on which style the user is editing.

For example, to modify the Blur, the user will input some value in a text field. To adjust the Brightness, the user will set a value on a range input field. To change the Object-Fit property, the user will select an option from a dropdown.

Let’s start by setting up the text field option. The text field will accept modifierName and type props to determine which style options the user can edit using the text field. We’ll also render a label, an input field, and a Save button in this component.

The text field component uses a bunch of values from the context provider, such as imageStyle, setCrystalizer, crystalizer, shard, setPointer, and pointer:

// src/components/text-field.jsx

import React, { useEffect, useState } from 'react';
import { useEditImage } from '../crystalizer/state';

export const TextField = ({ modifierName, type = 'text' }) => {
  const { imageStyle, setCrystalizer, crystalizer, shard, setPointer, pointer } = useEditImage();
Enter fullscreen mode Exit fullscreen mode

We will define a value state to control the local change in the text input when the user types a new value into the field. We’ll also define a handleChange function, which we’ll use to set the text input value to the state:

  const [value, setValue] = useState(0);

  const handleChange = (event) => {
    setValue(event.target.value);
  };
Enter fullscreen mode Exit fullscreen mode

Next, we’ll define a saveValue function, which we will call when the user clicks the Save button. We will also call a useEffect Hook, where we will set the default value and the jsx. See the code:

  const saveValue = () => {
    setCrystalizer(
      crystalizer
        .leave(pointer)
        .with({ id: new Date().getTime(), style: { ...shard.style, [modifierName]: Number(value) } })
    );
    setPointer(0);
  };

  useEffect(() => {
    setValue(shard?.style[modifierName]);
  }, [modifierName]);

  return (
    <div key={modifierName} className='editor-field'>
      <label htmlFor={modifierName}>
        {modifierName}: {imageStyle.style[modifierName === 'hue-rotate' ? 'hueRotate' : modifierName] ?? 0}
        {['blur'].includes(modifierName) && 'px'}
        {['hue-rotate'].includes(modifierName) && 'deg'}
      </label>
      <input
        type={type}
        placeholder={'Enter ' + modifierName}
        value={value}
        onChange={handleChange}
        key={modifierName}
      />
      <button type='button' onClick={saveValue}>
        Save
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The saveValue function performs two actions.

First, it updates the crystalizer using the setCrystalizer function. This function accepts a .leave method with the pointer state as the value and a .with method that accepts an object containing an id and the current style that we just applied.

The second action simply resets the value of the pointer state to zero.

Setting up the range input field

The range input is similar to the text input except that it implements an input with a type of range. The component accepts four props: modifierName, min, max, step. The code for this component should look like this:

// src/components/range-input-field.jsx

import React, { useEffect, useState } from 'react';
import { useEditImage } from '../crystalizer/state';

export const RangeInputField = ({ modifierName, min, max, step }) => {
  const { imageStyle, setCrystalizer, crystalizer, shard, setPointer, pointer } = useEditImage();
  const [value, setValue] = useState(0);

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const saveValue = () => {
    setCrystalizer(
      crystalizer.leave(0).with({ id: new Date().getTime(), style: { ...shard.style, [modifierName]: Number(value) } })
    );
    setPointer(0);
  };

  useEffect(() => {
    setValue(shard?.style[modifierName]);
  }, [modifierName]);

  return (
    <div key={modifierName} className='editor-field'>
      <label htmlFor={modifierName}>
        {' '}
        {modifierName}: {imageStyle.plain[modifierName]}
        {['brightness', 'contrast', 'grayscale', 'invert', 'sepia', 'opacity', 'scale'].includes(modifierName) && '%'}
      </label>
      <input
        type='range'
        id={modifierName}
        name={modifierName}
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={handleChange}
      ></input>
      <button type='button' onClick={saveValue}>
        Save
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

We’ll apply this range input field to the editor so that users can use it for the brightness, contrast, grayscale, invert, sepia, opacity, and scale modifiers.

Setting up the dropdown component

The SelectField component provides a dropdown with options so the user can change the object-fit property of the image they’re editing. It contains similar logic to the text field except that it uses an extra options memo, which checks for when the modifierName matches object-fit.

Here’s the code for our component:

// src/components/select-field.jsx

import React, { useEffect, useMemo, useState } from 'react';
import { useEditImage } from '../crystalizer/state';

export const SelectField = ({ modifierName }) => {
  const { imageStyle, setCrystalizer, crystalizer, shard, setPointer, pointer } = useEditImage();

  const [value, setValue] = useState('');

  const options = useMemo(() => {
    if (modifierName === 'object-fit') {
      return ['fill', 'contain', 'cover', 'none', 'scale-down'];
    }
  }, [modifierName]);

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const saveValue = () => {
    setCrystalizer(
      crystalizer.leave(pointer).with({ id: new Date().getTime(), style: { ...shard.style, [modifierName]: value } })
    );
    setPointer(0);
  };

  useEffect(() => {
    setValue(shard?.style[modifierName]);
  }, [modifierName]);

  return (
    <div key={modifierName} className='editor-field'>
      <label htmlFor={modifierName}>
        {' '}
        {modifierName}: {imageStyle.style[modifierName === 'object-fit' ? 'objectFit' : modifierName]}
      </label>
      <select
        name={modifierName}
        id={modifierName}
        placeholder={'Enter ' + modifierName}
        value={value}
        onChange={handleChange}
      >
        {options?.map((option) => {
          return (
            <option value={option} key={option}>
              {option}
            </option>
          );
        })}
      </select>
      <button type='button' onClick={saveValue}>
        Save
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

When the user selects the Object-Fit modifier, we render a label, our SelectField component with a set of mapped options defined in the options variable, and a Save button.

Setting up the editor panel

The image editor combines the DownloadButton, UndoButton, RedoButton, TextField, RangeInputField, and SelectField components we created in the previous sections. The code for this editor panel should look like this:

// src/components/select-field.jsx

import React from 'react';
import { useEditImage } from '../crystalizer/state';
import { DownloadButton } from './download-button';
import { RangeInputField } from './range-input-field';
import { RedoButton } from './redo-button';
import { SelectField } from './select-field';
import { TextField } from './text-field';
import { UndoButton } from './undo-button';

export const Editor = (props) => {
  const { modifier, imageUrl } = useEditImage();

  return (
    <div className='editor-wrapper'>
      <div className='buttons'>
        <DownloadButton />
        <div className='action-buttons'>
          <UndoButton />
          <RedoButton />
        </div>
      </div>
      {imageUrl && (
        <div className='editor-items'>
          <h2 className='editor-items-header'>Edit image</h2>
          {['height', 'width', 'blur'].includes(modifier) && <TextField modifierName={modifier} type='number' />}
          {['hue-rotate'].includes(modifier) && <TextField modifierName={modifier} type='number' />}
          {['brightness', 'contrast', 'grayscale', 'invert', 'sepia', 'opacity'].includes(modifier) && (
            <RangeInputField modifierName={modifier} max={100} min={0} step={1} />
          )}
          {['scale'].includes(modifier) && <RangeInputField modifierName={modifier} max={5} min={1} step={0.1} />}
          {['object-fit'].includes(modifier) && <SelectField modifierName={modifier} />}
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Remember, the selected modifier determines the type of input that we show to the user.

Now that we have completed the editor, we can pass the editor to the pages/index.jsx file. Now, our code will look like this:

import React from 'react';
import { Canvas } from '../components/canvas';
import { Editor } from '../components/editor';
import { Sidebar } from '../components/sidebar';
import { EditImageContextProvider } from '../crystalizer/state';

const Home = (props) => {
  return (
    <EditImageContextProvider>
      <div className='app-wrapper'>
        <Sidebar />
        <section className='canvas-editor-wrapper'>
          <Canvas />
          <Editor />
        </section>
      </div>
    </EditImageContextProvider>
  );
};
export default Home;
Enter fullscreen mode Exit fullscreen mode

At this moment, we will have a working React application that uses Crystalize.js to provide photo editing capabilities, undo and redo actions, and the ability to download the edited image. The final app should work as shown in the video below: https://www.youtube.com/watch?v=At-9Da_Oba8 See the source code for this project on GitHub.

Exploring two other Crystalize.js methods

Besides the methods that we discussed in the previous sections, Crystalize.js provides two other methods: .focus and .without.

You can use the .focus method to get specific shards in the crystal and modify them. Here’s how to use this method:

crystalizer.focus((shard) => shard.id === id);
Enter fullscreen mode Exit fullscreen mode

Like the .leave method, you can use the .without method to remove shards from the crystal. However, the .without method can remove a specific shard when a result is called without modifying the whole flow of the crystalizer, whereas the .leave method resets the counter.

Here’s how to use the .without method:

crystalizer.without((shard) => shard.id === id);
Enter fullscreen mode Exit fullscreen mode

In most practical scenarios, you won’t need to use the .focus or .without methods in real-world projects — the library likely included these methods just to be thorough. Especially in the case of the .without method, it’s usually better to use the .leave method.

Additional options you can pass to the Crystalizer

In the earlier sections of this article, we discussed how you can pass extra options while initializing the Crystalizer class. These options are sort, map, tsKey, and keep. Let’s explore them now.

The sort option accepts an array containing one or two arrays. It can be used like this:

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    console.log({ crystal, shard });
    return { id: shard.id + crystal.id, style: shard.style };
  },
  sort: [
        ['asc', 'id'],
       //  ['desc', (shard) => shard.style.width],
  ],
});
Enter fullscreen mode Exit fullscreen mode

As you can see from the example above, we’re instructing the crystalizer to sort the crystals in ascending order using the id. In the line of code that is commented out, we’re instructing the crystalizer to sort the crystal in descending order using the shard.style.width.

Next, suppose you don’t want to add keys from components, but instead want to automatically add keys or an id to each shard that is being added. You can use the map option to do that:

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    console.log({ crystal, shard });
    return { style: shard.style };
  },
  map: (shard) => {
    return {...shard, id: new Date().getTime()}
 }
});
Enter fullscreen mode Exit fullscreen mode

Essentially, the map option ensures that every shard added to the crystalizer will contain an id.

The keep option generally provides a default value for the .take method. Hence, you can use it to set the maximum number of shards that can be returned by the crystalizer or to filter the crystal automatically.

Here’s how to use the keep option:

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    console.log({ crystal, shard });
    return { style: shard.style };
  },
  keep: ['count', 10]
});
Enter fullscreen mode Exit fullscreen mode

Besides the count value, the keep option can also accept since, min, and max as values. Here’s an example that combines them all:

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    console.log({ crystal, shard });
    return { style: shard.style };
  },
  keep: ['min', [
        ['count',10],
        ['max', [
            ['count', 10],
            ['since', new Date().getTime() * 1000],
        ]],
});
Enter fullscreen mode Exit fullscreen mode

This will essentially return a minimum of 10 items and a maximum of 10 items from a specific date.

You can use the tsKey option to sort the shards in the crystal. In the example below, passing the tsKey option will sort our crystal in ascending order by default based on the specified key — in this case, the id:

let crystalizerInitializer = new Crystalizer({
  initial: initialData,
  reduce: (crystal, shard) => {
    console.log({ crystal, shard });
    return { id: shard.id + crystal.id, style: shard.style };
  },
 tskey: "id"
});
Enter fullscreen mode Exit fullscreen mode

While the example application we built in this tutorial doesn’t need these features — and these features may not be very useful in many practical scenarios — they are available to use with the Crystalize.js library.

Crystalize.js vs. Redux: Comparison table

While the Crystalize.js and Redux libraries can both be used to manage state, they serve different purposes. Redux is a global state management tool that helps make states available across an app. Meanwhile, Crystalize.js records states in order to replay actions taken in an application.

The table below summarizes the similarities and differences between Crystalize.js and Redux:

Crystalize.js Redux
Best known for… Capturing data changes and series of actions in an app Global state management
State management capabilities
Accepts actions ✅ Can accept new values as actions using the .with method ✅ Can accept actions
State-setting capabilities ✅ Can set state using reducers as in the reduce option ✅ Can set state using reducers
Provides a global state?
Provides a time-based state? ❌ Provides states that are accessed statically
Provides time travel in components?
Popularity Unclear yet, as it’s a very new library Very popular
Community Just one contributor Many active contributors

This table may be a helpful reference as you evaluate state management solutions. Make sure the option you choose offers any critical features that your project requires.

Conclusion

In this article, we looked at a practical demonstration of how to use the new Crystalize.js library by building a photo editing app that has undo, redo, and download functionalities. As a bonus, we looked at the similarities and differences it shares with Redux.

You can check GitHub for the full source code for the app that we implemented in this tutorial.

Thanks for reading! I hope you found this article useful for better understanding how to dynamically manage state with Crystalize.js. Be sure to leave a comment if you have any questions. Happy coding!

Top comments (0)