DEV Community

Chamal Lakshika Perera
Chamal Lakshika Perera

Posted on

Bringing 3D to Life: Integrating Three.js with React, Redux and MUI

The web is not a flat canvas. So why limit ourselves to two dimensions? With the rise of virtual reality and augmented reality, the future of web design is clear: it’s three-dimensional. This article will explore the synergy between Three.js, React, Redux for state management, and Material-UI (MUI) for polished interfaces, giving you hands-on examples straight from my GitHub repository.

Prerequisites

Before we dive deep, you should have a basic understanding of JavaScript, React, and Redux. Familiarity with Three.js and MUI is beneficial but not mandatory. Ensure your development environment includes Node.js, npm or yarn, and create-react-app.

Setting Up Your Environment

Let’s set up a React project, then add Three.js, Redux, and MUI:

npx create-react-app my-threejs-project
cd my-threejs-project
npm install three react-redux @reduxjs/toolkit @mui/material @mui/icons-material @emotion/react @emotion/styled
Enter fullscreen mode Exit fullscreen mode

Understanding Three.js Basics

Three.js is the gatekeeper to 3D rendering in the web browser, utilizing WebGL’s power. Its components, like scenes, cameras, renderers, geometries, materials, and lights, are the building blocks for our 3D world. We’ll demystify these terms and provide you with a roadmap for navigating the Three.js landscape.

Designing with MUI and Integrating Redux

MUI provides a robust set of components and styles for React applications, making our UI sleek and responsive. Redux, on the other hand, offers a structured approach to manage application state.

Setting up Redux: To integrate Redux, you’ll need to define actions, reducers, and then integrate the store with your application:

import { configureStore } from '@reduxjs/toolkit';
import cubeSlice from './cube-slice';

export const store = configureStore({
  reducer: {
    cube: cubeSlice,
  },
});
Enter fullscreen mode Exit fullscreen mode

Wrap your application with the Redux Provider:

import { Provider } from 'react-redux';
import store from './store';

function App() {
  return (
    <Provider store={store}>
      {/* ... rest of your components */}
    </Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Integrating MUI with Three.js Scene: For interaction controls like color changing, you can use MUI’s Button component:

import Button from '@mui/material/Button';

// ... within your component
<Button variant="contained" color="primary" onClick={() => setColor(0xff0000)}>Change Color</Button>
Enter fullscreen mode Exit fullscreen mode

Creating Your First React Component with Three.js

Now, we roll up our sleeves and dive into the code. Our mission: create a React component that houses our 3D scene. Instead of mixing our UI logic and 3D graphics logic, we’ll keep things clean by isolating the Three.js code in a separate file. This separation of concerns not only makes the code more manageable but also enhances reusability.

First, let’s create a new file to hold our Three.js logic, which we’ll call three-setup.js.

import * as THREE from 'three';

export const createScene = () => new THREE.Scene();

export const createCamera = () => {
  const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
  camera.position.z = 5;
  return camera;
};

export const createRenderer = (mount) => {
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  mount.appendChild(renderer.domElement);
  return renderer;
};

export const createCube = () => {
  const geometry = new THREE.BoxGeometry();
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
  return new THREE.Mesh(geometry, material);
};

export const animate = (renderer, scene, camera, cube) => {
  function animation() {
    requestAnimationFrame(animation);

    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    renderer.render(scene, camera);
  }
  animation();
};
Enter fullscreen mode Exit fullscreen mode

In three-setup.js, we've defined functions to set up the scene, camera, renderer, and cube, and to animate our object. Each function is responsible for one specific aspect of the 3D scene, making the code modular and easier to maintain or update.

Now, let’s use these functions in our main React component:

import React, { useRef, useEffect } from 'react';
import { createScene, createCamera, createRenderer, createCube, animate } from './three-setup';

function ThreeScene() {
  const mountRef = useRef(null);
  const cubeRef = useRef(null);

  useEffect(() => {
    const scene = createScene();
    const camera = createCamera();
    const renderer = createRenderer(mountRef.current);
    const cube = createCube();
    cubeRef.current = cube;

    scene.add(cube);

    animate(renderer, scene, camera, cube);

    return () => {
      mountRef.current.removeChild(renderer.domElement);
    };
  }, []);

  return <div ref={mountRef} />;
}

export default ThreeScene;
Enter fullscreen mode Exit fullscreen mode

In three-scene.js, we're importing our setup functions from three-setup.js. This way, the React component stays clean and focused on the component lifecycle, while the 3D setup and logic are abstracted away.

This separation is a common best practice in software development, known as the separation of concerns (SoC). It allows for better readability, easier maintenance, and scalability, especially when your project grows in complexity.

Managing Resources and Cleanups

In the world of 3D web graphics, performance is paramount. Efficient resource management — especially cleaning up and cancelling ongoing processes — is crucial when your components unmount. In React, this is typically done in the useEffect cleanup function, ensuring we're being good web citizens by cleaning up after ourselves to avoid memory leaks.

Let’s update our ThreeScene component to cancel the animation frame when the component unmounts.

First, we need to modify our animate function in three-setup.js to return the animation frame ID, which is returned by requestAnimationFrame.

export const animate = (renderer, scene, camera, cube) => {
  function animation() {
    const frameId = requestAnimationFrame(animation);

    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    renderer.render(scene, camera);

    return frameId; // We're returning the frame ID here
  }
  return animation(); // And initiating the animation here
};
Enter fullscreen mode Exit fullscreen mode

Now, in our three-scene.js, we'll store this animation frame ID and use it to cancel the animation on component unmount.

import React, { useRef, useEffect } from 'react';
import { createScene, createCamera, createRenderer, createCube, animate } from './three-setup';

function ThreeScene() {
  const mountRef = useRef(null);

  useEffect(() => {
    const scene = createScene();
    const camera = createCamera();
    const renderer = createRenderer(mountRef.current);
    const cube = createCube();
    cubeRef.current = cube;

    scene.add(cube);

    const frameId = animate(renderer, scene, camera, cube); // Storing the frame ID

    return () => {
      mountRef.current.removeChild(renderer.domElement);
      cancelAnimationFrame(frameId); // Cancelling the animation frame here
    };
  }, []);

  return <div ref={mountRef} />;
}

export default ThreeScene;
Enter fullscreen mode Exit fullscreen mode

With the cancelAnimationFrame function, we tell the browser to stop the ongoing animation process associated with the provided frame ID. This is crucial for preventing potential memory leaks, ensuring that the JavaScript heap size doesn't grow indefinitely due to abandoned objects and closures from the obsolete component instance.

Managing State, Interactivity, and Redux Integration

What’s a 3D object if it can’t interact with your users? A static entity. We’ll breathe life into our 3D objects by managing component state and handling user interactions like clicks, drags, and more.

Add a Redux action to manage color changes:

import { createSlice } from '@reduxjs/toolkit';

export const cubeSlice = createSlice({
  name: 'cube',
  initialState: {
    color: 0x00ff00,
  },
  reducers: {
    setColor: (state, action) => {
      state.color = action.payload;
    }
  },
});

export const { setColor } = cubeSlice.actions;
export default cubeSlice.reducer;

export const selectColor = (state) => state.cube.color;
Enter fullscreen mode Exit fullscreen mode

Update your component to dispatch this action:

import { useDispatch } from 'react-redux';
import { setColor } from './cube-slice';

function ThreeScene() {
  const dispatch = useDispatch();
  const color = useSelector(selectColor);
  // ... other component logic

  useEffect(() => {
    if (cubeRef.current) {
      cubeRef.current.material.color.set(color);
    }
  }, [color]);

  return (
    <>
      <div ref={mountRef} />
      <Button onClick={() => dispatch(setColor(0xff0000))}>Change Color</Button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

With this code, we introduce interactivity, allowing users to engage with our 3D object in real time. To see the complete implementation, check out the corresponding code in my GitHub repository.

Image description

Conclusion

Merging Three.js with React might seem daunting initially, but the rewards are well worth the effort. The blend of Three.js’s graphical capabilities with React’s efficient rendering opens up a universe of possibilities for web developers and UI/UX designers. So embark on this journey, experiment, learn, and most importantly, have fun creating something magical!

If you want to delve deeper and explore the codebase, feel free to browse the project on GitHub. Embark on this journey, experiment, learn, and most importantly, have fun creating something magical!

Further Resources

For those who want to explore deeper waters, check out the Three.js Documentation, React Documentation, Redux Documentation, MUI Documentation, react-three-fiber on GitHub and the complete code for this tutorial on my GitHub.

Top comments (0)