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:
- What is Crystalize.js?
- Setting up a React application with Crystalize.js
- Setting up the Context API
- Initializing Crystalize.js
- Setting up states
- Setting up helper functions
- Setting up the
imageStyle
memo - Styling the app and creating UI components
- Exploring two other Crystalize.js methods
- Additional options you can pass to the
Crystalizer
- Crystalize.js vs. Redux: Comparison table
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
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
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
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
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;
};
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;
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
//...
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);
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 thecrystalizerInitializer
and any subsequent updates to thecrystalizer
- The
modifier
state holds the aspect of the imagestyle
that the user chooses to modify — in the code above, thewidth
- The
pointer
state holds a value we can pass to the.leave
method to determine how manyshards
we remove from thecrystalizer
— 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,
},
]
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,
}]
*/
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',
},
})
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 });
});
}, []);
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'];
}
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]);
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,
}}
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;
};
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);
}
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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;
At this moment, our application should look like this: 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>
);
};
When we haven't yet selected an image, our application will look similar to this: After uploading an image, the app will render the selected image in the canvas like so: 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>
);
};
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>
);
};
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>
);
};
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();
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);
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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;
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);
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);
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],
],
});
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()}
}
});
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]
});
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],
]],
});
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"
});
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)