π€― Building your first Neuro App with React
Alex Castillo
Posted on January 31, 2020
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.
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:
π 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>
);
}
π 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}
/>
);
}
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]);
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>
);
}
π 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";
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]);
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]);
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;
}
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>
);
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.
- simulation.js
- weather.js
- Ocean.js:
// 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>;
}
And if all goes well, we should see this.
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. TheSimulator
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]);
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
to1.0
. The higher the score, the higher the probability a calm feeling is detected. Getting a calm score over0.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} />
);
}
π‘ 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>
);
And with that, our Neuro React App is now complete.
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...
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
November 24, 2023
September 29, 2023