🀯 Building your first Neuro App with React

alexcastillo

Alex Castillo

Posted on January 31, 2020

🀯 Building your first Neuro App with React

Most apps today change state based on user intent. To be more specific, hand movements that translate into clicks, taps, presses, etc. Yet, every single intent starts in our brains.

Today, we are going to build a different type of app. We'll build an app that changes state based on your cognitive state.

Hear me out.

What if our app changed the motion of a WebGL ocean based on your calm level? A "visual meditation" experience driven by the way you are feeling.

The first step would be to measure and access such data. And for that, we'll use a Notion headset.

Tweet Introducing Notion

Tweet Introducing Notion

Getting Started

Let's start by bootstrapping our app with Create React App (CRA). We open the project in VS Code and run the app locally.

  • npx create-react-app mind-controlled-ocean
  • code mind-controlled-ocean
  • npm start

If all goes well, you should see something like this:

Create React App Default View


Create React App Default View

πŸ”‘ Authentication

We believe in privacy. That's why Notion is the first brain computer to feature authentication. Adding auth to the app is pretty straightforward. For this, we'll need a login form and 3 side effects to sync the authentication state.

All you need to connect to your Notion brain computer is a Neurosity account and a Device ID. So, let's start by creating a new component for the login form that will collect this information.

// src/components/LoginForm.js
import React, { useState } from "react";

export function LoginForm({ onLogin, loading, error }) {
  const [deviceId, setDeviceId] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  function onSubmit(event) {
    event.preventDefault();
    onLogin({ deviceId, email, password });
  }

  return (
    <form className="card login-form" onSubmit={onSubmit}>
      <h3 className="card-heading">Login</h3>
      {!!error ? <h4 className="card-error">{error}</h4> : null}
      <div className="row">
        <label>Notion Device ID</label>
        <input
          type="text"
          value={deviceId}
          disabled={loading}
          onChange={e => setDeviceId(e.target.value)}
        />
      </div>
      <div className="row">
        <label>Email</label>
        <input
          type="email"
          value={email}
          disabled={loading}
          onChange={e => setEmail(e.target.value)}
        />
      </div>
      <div className="row">
        <label>Password</label>
        <input
          type="password"
          value={password}
          disabled={loading}
          onChange={e => setPassword(e.target.value)}
        />
      </div>
      <div className="row">
        <button type="submit" className="card-btn" disabled={loading}>
          {loading ? "Logging in..." : "Login"}
        </button>
      </div>
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

πŸ– Add styles the form - here's the CSS I used.

This component will hold the state of the deviceId, email and password. Additionally, our form component will accept an onLogin prop that will execute when the user clicks on the "Login" button. We'll also accept a loading prop for when the form submission is in progress, and an error message prop to be displayed when an error occurs.

Now that we've created our login component, let's add a login page that will make use of our new component.

// src/pages/Login.js
import React, { useState, useEffect } from "react";
import { LoginForm } from "../components/LoginForm";

export function Login({ notion, user, setUser, setDeviceId }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [isLoggingIn, setIsLoggingIn] = useState(false);

  function onLogin({ email, password, deviceId }) {
    if (email && password && deviceId) {
      setError("");
      setEmail(email);
      setPassword(password);
      setDeviceId(deviceId);
    } else {
      setError("Please fill the form");
    }
  }

  return (
    <LoginForm
      onLogin={onLogin}
      loading={isLoggingIn}
      error={error}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Login form


Login Form Component

The goal of this page is to display the login form, add basic form validation via the setError function, and execute a login function. For the latter, let's add a side effect that will sync with email, password and the props received the page.

useEffect(() => {
  if (!user && notion && email && password) {
    login();
  }

  async function login() {
    setIsLoggingIn(true);
    const auth = await notion
      .login({ email, password })
      .catch(error => {
        setError(error.message);
      });

    if (auth) {
      setUser(auth.user);
    }

    setIsLoggingIn(false);
  }
}, [email, password, notion, user, setUser, setError]);
Enter fullscreen mode Exit fullscreen mode

You can think of user as the object that holds the auth user session set by the Notion API. So we are only calling our login() function if there is no auth session, we have a Notion instance in the state, and the user has submitted an email and password.

Very soon you'll find out how we'll receive the props: notion, user, setUser, setDeviceId. But before we do that, let's go back to our App.js and start putting it all together.

βš™οΈ App State

To keep this app simple, we'll just be using React's useState hook, the Reach Router, and a local storage hook brought to you by react-use πŸ‘. This means our general application state strategy will consist of keeping the global state at the App component level and passing down the necessary props to its child components.

  • npm install @reach/router react-use

We'll start with a single route, but we'll add 2 more routes as we continue to build the app.

// src/App.js
import React, { useState, useEffect } from "react";
import { Router, navigate } from "@reach/router";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Login } from "./pages/Login";

export function App() {
  const [notion, setNotion] = useState(null);
  const [user, setUser] = useState(null);
  const [deviceId, setDeviceId] = useLocalStorage("deviceId");
  const [loading, setLoading] = useState(true);

  return (
    <Router>
      <Login
        path="/"
        notion={notion}
        user={user}
        setUser={setUser}
        setDeviceId={setDeviceId}
      />
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ† I found the Reach Router by Ryan Florence (and friends) to be the perfect balance between simplicity and predictability. Their documentation is extremely clear and allowed me to implement basic routing within minutes.

If you were wondering why have we decided to keep the deviceId in the local storage, it is because we'll need to access it before and after the user has logged in. It also makes a nicer user experience not to have to enter it multiple times.

🧠 Notion

Now that we have basic state management in place, let's integrate our app with Notion by installing the API and importing it in App.js.

  • npm install @neurosity/notion

🀯 The Notion API enables full communication between the device and the app. We'll use it to get real-time feedback based on the user's cognitive state. For this app, we'll work specifically with the calm API.

import { Notion } from "@neurosity/notion";
Enter fullscreen mode Exit fullscreen mode

Connecting to a Notion device is simple. We instantiate a new Notion and pass the Device ID. We can add a side effect that sets the instance to the App component state by syncing with deviceId.

πŸ“– You can find the full Notion documentation at docs.neurosity.co.

useEffect(() => {
  if (deviceId) {
    const notion = new Notion({ deviceId }); // 😲
    setNotion(notion);
  } else {
    setLoading(false);
  }
}, [deviceId]);
Enter fullscreen mode Exit fullscreen mode

Another state we want to sync is the user state.

In the following example, we'll add a side effect that syncs with the value of the notion instance. If notion hasn't been set yet, then we'll skip subscribing to calm events until the notion instance is created.

useEffect(() => {
  if (!notion) {
    return;
  }

  const subscription = notion.onAuthStateChanged().subscribe(user => {
    if (user) {
      setUser(user);
    } else {
      navigate("/");
    }
    setLoading(false);
  });

  return () => {
    subscription.unsubscribe();
  };
}, [notion]);
Enter fullscreen mode Exit fullscreen mode

If the app has an active user session persisted by the Notion authentication, we'll want to get the current logged in user, and set it to the state in our App component.

The onAuthStateChanged method returns an observable of user auth events. It is important to note that when using the Notion API in the browser, the session will persist via local storage. So, if you close the app, or reload the page, the session will persist and onAuthStateChanged will return the user session instead of null. This is exactly what we want.

If no session is detected we can navigate to the login page. Otherwise, set user in the component's state.

We can complete full authentication by adding a Logout page.

// src/pages/Logout.js
import { useEffect } from "react";
import { navigate } from "@reach/router";

export function Logout({ notion, resetState }) {
  useEffect(() => {
    if (notion) {
      notion.logout().then(() => {
        resetState();
        navigate("/");
      });
    }
  }, [notion, resetState]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

The logout page is simply a React component with no DOM elements. The only logic we need is a side effect that will call the notion.logout() method if the notion instance is present. Lastly, it redirects the user to the initial route after logging out.

This component can now be added as a route in App.js.

// src/App.js
// ...
import { Logout } from "./pages/Logout";
// ...

return (
  <Router>
    {/* ... */}
    <Logout path="/logout" notion={notion} resetState={() => {
      setNotion(null);
      setUser(null);
      setDeviceId("");
    }} />
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

Now that authentication is completed, let's add App logic based on our cognitive state!

🌊 WebGL Ocean

The moment I saw David's WebGL ocean, I fell in love with it. So using Notion to influence the weather driving the ocean waves felt like a fun experiment.

πŸ’‘ Fun fact: This ocean wave simulation was created in 2013 by David Li with JavaScript and WebGL. It has been featured as part of Google Experiments. I was happy to see it was open-sourced under the MIT license.

For this next part, the idea is to create a new component that will use the WebGL ocean. So let's create a directory called Ocean (./src/components/Ocean) and add the following files to it.

// src/components/Ocean/Ocean.js
import React, { useState, useEffect, useRef } from "react";
import useRafState from "react-use/lib/useRafState";

import { Simulator, Camera } from "./simulation.js"; // by David Li
import { mapCalmToWeather } from "./weather.js";

const camera = new Camera();

export function Ocean({ calm }) {
  const ref = useRef();
  const [simulator, setSimulator] = useState();
  const [lastTime, setLastTime] = useRafState(Date.now());

  useEffect(() => {
    const { innerWidth, innerHeight } = window;
    const simulator = new Simulator(ref.current, innerWidth, innerHeight);
    setSimulator(simulator);
  }, [ref, setSimulator]);

  useEffect(() => {
    if (simulator) {
      const currentTime = Date.now();
      const deltaTime = (currentTime - lastTime) / 1000 || 0.0;
      setLastTime(currentTime);
      simulator.render(deltaTime, camera);
    }
  }, [simulator, lastTime, setLastTime]);

  return <canvas className="simulation" ref={ref}></canvas>;
}
Enter fullscreen mode Exit fullscreen mode

And if all goes well, we should see this.

Ocean Wave Simulation


Ocean Wave Simulation

Let me break down what's happening here.

  • 1️⃣ The React component returns a canvas element for the WebGL 3D scene
  • 2️⃣ We use React's useRef to access the canvas HTML element
  • 3️⃣ We instantiate a new Simulator when the reference changes. The Simulator class is responsible for controlling rendering, and the weather properties such as wind, choppiness, and size.
  • 4️⃣ We use the useRaf (requestAnimationFrame) hook to create a loop where the callback executes on every animation frame.

At this point, our ocean waves move based on static weather values: choppiness, wind, and size. So, how do we map these weather settings based on the calm score?

For that, I've created a utility function in weather.js for mapping the calm score to its corresponding weather settings: choppiness, wind, and size. And then, we can create a side effect that syncs every time the calm score changes.

useEffect(() => {
  if (simulator) {
    setWeatherBasedOnCalm(animatedCalm, 0, 0);
  }

  function setWeatherBasedOnCalm(calm) {
    const { choppiness, wind, size } = mapCalmToWeather(calm);
    simulator.setChoppiness(choppiness);
    simulator.setWind(wind, wind);
    simulator.setSize(size);
  }
}, [calm, simulator]);
Enter fullscreen mode Exit fullscreen mode

Cognitive State

This is the fun part. This is where we get to access brain data and map it to the app state.

By subscribing to notion.calm(), we get a new calm score approximately every second. So, let's add the <Ocean calm={calm} /> component, add calm as a prop and create a side effect that syncs with the instance of notion and with user. If these two states are present, then we can safely subscribe to calm.

πŸ§˜πŸΏβ€β™€οΈ The calm score is derived from your passive cognitive state. This metric based on the alpha wave. The calm score ranges from 0.0 to 1.0. The higher the score, the higher the probability a calm feeling is detected. Getting a calm score over 0.3 is significant. Things that can help increase the calm score include closing your eyes, keeping still, breathing deeply, or meditating.

// src/pages/Calm.js
import React, { useState, useEffect } from "react";
import { Ocean } from "../components/Ocean/Ocean";

export function Calm({ user, notion }) {
  const [calm, setCalm] = useState(0);

  useEffect(() => {
    if (!user || !notion) {
      return;
    }

    const subscription = notion.calm().subscribe(calm => {
      const calmScore = Number(calm.probability.toFixed(2));
      setCalm(calmScore);
    });

    return () => {
      subscription.unsubscribe();
    };
  }, [user, notion]);

  return (
    <Ocean calm={calm} />
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ All notion metrics, including notion.calm() return an RxJS subscription that we can use to safely unsubscribe when the component unmounts.

And finally, we add our Calm page to App.js.

// src/App.js
// ...
import { Calm } from "./pages/Calm";
// ...

// If already authenticated, redirect user to the Calm page
useEffect(() => {
  if (user) {
    navigate("/calm");
  }
}, [user]);

return (
  <Router>
    {/* ... */}
    <Calm path="/calm" notion={notion} user={user} />
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

And with that, our Neuro React App is now complete.

GitHub logo neurosity / notion-ocean

🌊 Use a brain computer to control the motion of a WebGL ocean


I'm excited about app experiences that are influenced by who we are as a person. Every brain is different, yet we keep building apps that present the same experience to every user. What if apps were tailored to you?.

What if apps could help you relax when you are stressed?

What if you could authenticate an app with your brainwaves?

What if video games could change their narrative based on your feelings?

What if...

πŸ’– πŸ’ͺ πŸ™… 🚩
alexcastillo
Alex Castillo

Posted on January 31, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related