Skip to content

Chapter 3 - Create a React Context to store the lines

As the application gain in complexity, you will need to add some modularity in to become more maintainable. This chapter of the tutorial will show an example of how you can use the React Context feature.

In this chapter, you will take the state you did in the last chapter and put it in dedicated component. This other component will be a Provider, that will provide for all its children component access to its Context, containing all the drawing data state.

You will also create a dedicated folder for the types used in this drawing application to make full usage of TypeScript.

Info

All the following steps will be executed in the frontend directory.

In a new terminal, you can switch to the frontend directory with the following command.

In a terminal, execute the following command(s).
cd frontend

Steps

Moves the types to their own files

To keep your application clean, you'll move the types you created in the previous chapter in a dedicated types directory.

Create all the required types.

frontend/src/types/point.ts
export type Point = { x: number; y: number }; // (1)!
  1. Point was taken from Sketch.tsx and exported, so all the frontend has access to it.
frontend/src/types/line-data.ts
export type LineData = number[]; // (1)!
  1. Line was taken from Sketch.tsx and exported, so all the frontend has access to it.
frontend/src/types/lines-data.ts
1
2
3
import { LineData } from "./line-data";

export type LinesData = LineData[]; // (1)!
  1. LinesData was taken from Sketch.tsx and exported, so all the frontend has access to it.

Update the Sketch component to use the types.

frontend/src/components/sketch.tsx
import React from 'react';
import Konva from 'konva';
import { Stage, Layer, Line } from 'react-konva';
import { LinesData } from '@/types/lines-data';
import { Point } from '@/types/point';

type State = LinesData;

export const Sketch = () => {
	const [lines, setLines] = React.useState<State>([]);
	const isDrawing = React.useRef(false);

	const getPointFromMouseEvent = (
		mouseEvent: Konva.KonvaEventObject<MouseEvent>
	) => {
		return mouseEvent.target.getStage()?.getPointerPosition() ?? { x: 0, y: 0 };
	};

	const addFirstPoint= (point: Point) => {
		setLines([...lines, [point.x, point.y] ]);
	}

	const addPoint = (point: Point) => {
		const linesCopy = [...lines];
		let [lastLine] = linesCopy.splice(-1);
		setLines([...linesCopy, [...lastLine, point.x, point.y]]);
	}

	const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
		isDrawing.current = true;
		const point = getPointFromMouseEvent(e);
		addFirstPoint(point);
	};

	const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
		if (!isDrawing.current) {
			return;
		}
		const point = getPointFromMouseEvent(e);
		addPoint(point);
	};

	const handleMouseUp = () => {
		isDrawing.current = false;
	};

	return (
		<div>
			<Stage
				width={window.innerWidth}
				height={window.innerHeight}
				onMouseDown={handleMouseDown}
				onMousemove={handleMouseMove}
				onMouseup={handleMouseUp}
			>
				<Layer>
					{lines.map((line, i) => (
						<Line
							key={i}
							points={line}
							stroke="#df4b26"
							strokeWidth={5}
							tension={0.5}
							lineCap="round"
							lineJoin="round"
						/>
					))}
				</Layer>
			</Stage>
		</div>
	);
};

Create the Line Provider

A provider is a React component that can give access to its children to some properties. The children can then use the provider and gain access to its state, allowing to make changes that are shared with all other components having access to this context.

In this application, the provider should keep the state of the drawing data for the sketch. What do the Sketch component expects from it?

  • Access to the lines of the drawings
  • A way to modify it by starting a new line or adding a point to an existing line

For that the LineProvider will provide:

  • A state (State) containing the lines (lines)
  • A dispatch function (Dispatch) to update the state;
  • Two actions (ADD_FIRST_POINT and ADD_POINT) modifying the State;

Create the line provider.

frontend/src/components/line-provider.tsx
import { LinesData } from "@/types/lines-data"; // (1)!
import { Point } from "@/types/point"; // (2)!
import { ReactNode, createContext, useContext, useReducer } from "react";

type ActionName = "ADD_POINT" | "ADD_FIRST_POINT"; // (3)!

export type Action = { type: ActionName; point: Point }; // (4)!
export type Dispatch = (action: Action) => void; // (5)!
export type State = LinesData; // (6)!

const LineStateContext = createContext<
	{ state: State; dispatch: Dispatch } | undefined
>(undefined); // (7)!

function useLines() { // (8)!
	const context = useContext(LineStateContext);
	if (context === undefined) {
		throw new Error("useLines must be used within a LineProvider");
	}
	return context;
}

function lineReducer(state: State, action: Action): State { // (9)!
	const { type, point } = action;

	switch (type) {
		case "ADD_FIRST_POINT": {
			return [...state, [point.x, point.y]];
		}
		case "ADD_POINT": {
			const lines = [...state];

			let [lastLine] = lines.splice(-1);
			return [...lines, [...lastLine, point.x, point.y]];
		}
		default: {
			throw new Error(`Unhandled action type: ${type}`);
		}
	}
}

function LineProvider({ children }: { children: ReactNode }) { // (10)!
	const [state, dispatch] = useReducer(lineReducer, []); // (11)!

	return (
		<LineStateContext.Provider value={{ state, dispatch }}> // (12)!
			{children}
		</LineStateContext.Provider>
	);
}

export { LineProvider, useLines };
  1. Import of the shared types.
  2. Import of the shared types.
  3. Define labels for the available actions.
  4. An action is defined by a type (which action to perform on the state) and a point (data passed to the action).
  5. The dispatch function asks the state to perform the action on itself.
  6. The state type.
  7. Creation of the context. It has a State that contains the lines and a Dispatch function. It's initialized with undefined.
  8. It's a method used to access the Context from all the children of the Provider. You could only use useContext() but you see here a proper way that test if it's a children that ask for the Context.
  9. The function addFirstPoint() and addPoint() from Sketch were put here, in a reducer. This reducer defines Action to modify the State
  10. The children will be wrapped by your Context Provider.
  11. The useReducer function creates and initialize the reducer.
  12. Pass the current state and the dispatch function to the provider you created.

Update the main page

The LineProvider component can now wraps all components that need to have access to the lines of the drawing.

The LineProvider wraps the Sketch component so it will adopt it as one of its children and provides it with a Context to give it access to the lines.

frontend/src/pages/index.tsx
import { LineProvider } from '@/components/line-provider';
import dynamic from 'next/dynamic';

const Sketch = dynamic(
	() => import('../components/sketch').then((mod) => mod.Sketch),
	{
		ssr: false,
	},
);

export default function Home() {
	return (
		<LineProvider>
			<Sketch />
		</LineProvider>
	)
}

Update the sketch

The sketch can now have access to the context of the LineProvider.

Update the sketch to use the provider.

frontend/src/components/sketch.tsx
import React from 'react';
import Konva from 'konva';
import { Stage, Layer, Line } from 'react-konva';
import { useLines } from '@/components/line-provider'; // (1)!

export const Sketch = () => {
	const { state: lines, dispatch: dispatchLines } = useLines(); // (2)!
	const isDrawing = React.useRef(false);

	const getPointFromMouseEvent = (
		mouseEvent: Konva.KonvaEventObject<MouseEvent>
	) => {
		return mouseEvent.target.getStage()?.getPointerPosition() ?? { x: 0, y: 0 };
	};

	const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
		isDrawing.current = true;
		const point = getPointFromMouseEvent(e);
		dispatchLines({ type: "ADD_FIRST_POINT", point: point }); // (3)!
	};

	const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
		if (!isDrawing.current) {
			return;
		}
		const point = getPointFromMouseEvent(e);
		dispatchLines({ type: "ADD_POINT", point: point }); // (4)!
	};

	const handleMouseUp = () => {
		isDrawing.current = false;
	};

	return (
		<div>
			<Stage
				width={window.innerWidth}
				height={window.innerHeight}
				onMouseDown={handleMouseDown}
				onMousemove={handleMouseMove}
				onMouseup={handleMouseUp}
			>
				<Layer>
					{lines.map((line, i) => (
						<Line
							key={i}
							points={line}
							stroke="#df4b26"
							strokeWidth={5}
							tension={0.5}
							lineCap="round"
							lineJoin="round"
						/>
					))}
				</Layer>
			</Stage>
		</div>
	);
};
  1. Import the useLines function.
  2. Importation of the State and Dispatch, and renaming to better understanding.
  3. Dispatch to the LineProvider to add the first point in a line.
  4. Dispatch to the LineProvider to add a point in a line.

Check the results

Start the application and access http://localhost:3000. You should see the exact same result as earlier.

To stop your Next.js application, press Ctrl+C in your terminal.

Summary

Congrats! The big improvement is the React Context. You now have a shared context to use and manipulated the lines. This will allow other components to add points to the drawing in future chapters.

Go further: a note on State, Immutability and Pure functions

Introduction

You may have asked yourself: why the methods used to modify the lines are so complicated? (We're not going to ask Avril Lavigne)

One can easy think that the following code...

frontend/src/components/line-provider.tsx
function lineReducer(state: State, action: Action): State {
	const { type, point } = action;

	switch (type) {
		case "ADD_FIRST_POINT": {
			return [...state, [point.x, point.y]];
		}
		case "ADD_POINT": {
			const lines = [...state];

			let [lastLine] = lines.splice(-1);
			return [...lines, [...lastLine, point.x, point.y]];
		}
		default: {
			throw new Error(`Unhandled action type: ${type}`);
		}
	}
}

...could be done like this:

frontend/src/components/line-provider.tsx
function lineReducer(state: State, action: Action): State {
	const { type, point } = action;

	switch (type) {
		case "ADD_FIRST_POINT": {
			state.push([point.x, point.y]);
			break;
		}
		case "ADD_POINT": {
			const lastLine = state[state.length - 1];

			lastLine.push(point.x);
			lastLine.push(point.y);
			break;
		}
		default: {
			throw new Error(`Unhandled action type: ${type}`);
		}
	}
}

However, this is not recommended in React. You have to keep the state immutable.

Definition

As the Wikipedia's page of Immutable object mentions:

Quote

In object-oriented and functional programming, an immutable object (unchangeable object) is an object whose state cannot be modified after it is created. This is in contrast to a mutable object (changeable object), which can be modified after it is created.

In React, state should be immutable. The actions needs to modify the state as immutable objects. A function that does this is called a "pure function".

It means that you cannot modify the State inside a reducer directly, you have to modify a copy of it, and then give a new version back to the reducer.

Methods like push, unshift and splice are mutative so you can't use it when applying updates to your State.

See this article for more details.

Also, you need to know that in development mode, React always sends the dispatch() twice. Don't put mutative code in your reducer, it will not work accordingly. More on what here: React Strict Mode.

What are the alternatives

Well, state manipulations are not that easy, and it can be worth using something that help us don't make mistakes.

You can use Immer to handle the immutability for you.

Demonstration

Install Immer.

In a terminal, execute the following command(s).
npm install --save immer

You can modify line-provider.tsx as follow.

frontend/src/components/line-provider.tsx
// ...
import { produce } from "immer";

// ...

function lineReducer(state: State, action: Action): State {
	const { type, point } = action;

	switch (type) {
		case "ADD_FIRST_POINT": {
			return produce(state, (draft: State) => {
				draft.push([point.x, point.y])
			});
		}
		case "ADD_POINT": {
			return produce(state, (draft: State) => {
				const lastLine = draft[draft.length - 1];
				lastLine.push(point.x);
				lastLine.push(point.y);
			});
		}
		default: {
			throw new Error(`Unhandled action type: ${type}`);
		}
	}
}

With the produce() method, no need to worry about immutability, the changes on draft will be applied to the State, and manages everything about immutability with the help of Immer.