4 min read
Managing Data Serialization in Redux
When working on a React-based canvas application, I recently encountered a series of challenges while trying to efficiently manage custom canvas nodes using Redux for state management and Konva for rendering. This blog post chronicles the issues I faced, the troubleshooting process, and the final solution that brought everything together.
The Problem: Managing Non-Serializable Class
My project required a canvas setup where users could drag and drop various shapes (rectangles, circles, etc.) onto a canvas. To manage the state of these nodes, I initially used Redux. However, I quickly ran into an issue: Redux requires that all values in its state be serializable, but I was trying to store class instances (e.g., RectNode
) directly in Redux. This approach triggered errors because these class instances are not serializable.
The error message I encountered was:
"A non-serializable value was detected in an action, in the path: payload. Value: RectNode {...}"
This error indicated that my approach needed to be rethought.
Solution: Separating Data and Behavior
After analyzing the problem, I realized that the core issue was trying to store too much in Redux. My custom classes encapsulated both data and behavior, but Redux should only be concerned with serializable data. Therefore, I decided to decouple the data from the behavior.
Step 1: Storing Serializable Data in Redux
I refactored my code to define an interface, CanvasNodeData
, that represents the data required to render a node on the canvas. This data included properties like id
, type
, x
, y
, props
, and style
. By storing only this serializable data in Redux, I could avoid the non-serializable value error.
// Define the interface for the data that will be stored in the Redux storeexport interface CanvasNodeData { id: string; type: string; x: number; y: number; props: { [key: string]: any }; // Use an object for key-value pairs draggable?: boolean; style?: React.CSSProperties;}
Step 2: Node Data
To streamline the creation of nodes, I created a utility function that generates the CanvasNodeData
object. This function handles the ID generation and ensures that the props
are properly structured.
export const createNodeData = (node: Omit<CanvasNodeData, 'id'>): CanvasNodeData => { return { id: generateId(), // Generate the id internally type: node.type, x: node.x, y: node.y, props: node.props, draggable: node.draggable ?? true, // Default to true if not specified style: node.style, };};
With this setup, adding a new node to the canvas became a straightforward process:
const rect = utils.createNodeData({ type: "rect", x: 100, y: 100, props: { width: 100, height: 100, fill: "blue", },});dispatch(canvasNodeSliceActions.addNode(rect));
Restoring Class Behavior During Rendering
While I had solved the serialization problem, another issue arose: my custom classes (RectNode
, CircleNode
, etc.) encapsulated not only data but also behavior, such as event handlers. When rendering the nodes directly from the serialized data in Redux, this behavior was lost.
Reintroducing Class Instances at Render Time
To resolve this, I introduced a mapping function, createCanvasNodeInstance
, that converts the serialized data back into class instances during rendering. This allowed me to restore the custom behavior and event handlers.
export const createCanvasNodeInstance = (data: CanvasNodeData): CanvasNode => { switch (data.type) { case "rect": return new RectNode(data); // Assuming RectNode is a class extending CanvasNode case "circle": return new CircleNode(data); // Assuming CircleNode is a class extending CanvasNode case "group": return new GroupNode(data); // Assuming GroupNode is a class extending CanvasNode default: throw new Error(`Unknown node type: ${data.type}`); }};
In the Node
component, I utilized this function to ensure that each node was rendered with its full behavior intact:
const Node = ({ data }: { data: CanvasNodeData }) => { const nodeInstance = utils.createCanvasNodeInstance(data);
switch (data.type) { case "rect": return <Rect {...nodeInstance.props} onDragEnd={nodeInstance.handleDragEnd} />; case "circle": return <Circle {...nodeInstance.props} onDragEnd={nodeInstance.handleDragEnd} />; case "group": return <Group {...nodeInstance.props} onDragEnd={nodeInstance.handleDragEnd} />; default: return null; }};
Finally, I rendered the nodes within the Layer
component of my canvas:
<Layer> {nodes.map((node) => ( <Node key={node.id} data={node} /> ))}</Layer>
Conclusion
By separating the concerns of data management and behavior, I was able to maintain a clean and modular codebase. Redux now handles only the serialized data, ensuring compliance with best practices, while the custom behavior of my nodes is reintroduced during rendering through class instances.
This approach not only solved the immediate issues but also provided a scalable solution that can be easily extended with new node types or behaviors in the future.
The journey from encountering the non-serializable value error to arriving at this solution was a valuable learning experience, reinforcing the importance of keeping data and behavior separate in complex applications. If you’re facing similar challenges in your projects, I hope this post provides useful insights and practical strategies to overcome them.