Pyodide allows you to run Python code within the browser via WebAssembly (wasm). It's a great option if, like me, you're someone who wants to escape some of the limitations of working with JavaScript.
Getting things up and running involves a few steps, described in the Pyodide docs:
- Include Pyodide.
- Set up the Python environment (load the Pyodide wasm module and initialize it).
- Run your Python code.
Cool, but it'd be nice to handle all of this in a reusable React component. How can we make it work?
Let's take it step by step.
Step 1: Include Pyodide
The first task is easy enough: add a script
tag to the document head
with the Pyodide CDN url as the src
attribute. Better yet, if you're using a framework like Gatsby or Next.js (I used the latter for this example), wrap your script
inside a built-in Head
component that will append tags to the head
of the page for you (react-helmet is another great option). That way you won't have to worry about accidentally forgetting to include Pyodide in your project, since it's already part of your component.
Let's call our component Pyodide
. Here's what we have so far:
import Head from 'next/head'
export default function Pyodide() {
return (
<Head>
<script src={'https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js'} />
</Head>
)
}
Step 2: Set Up Python Environment
Here things get tricky.
Our script will attach a function called loadPyodide
to the global object of our environment. In the browser, this is the window
object, but more generally it is called globalThis
. As long as our script is loaded, we can call this function as follows, where indexURL
is a string matching the first part of the CDN url from earlier:
globalThis.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/dev/full/'
})
The return value of loadPyodide
is the Pyodide module itself, which we will eventually call to run our Python code. Can we simply assign the result to a variable? Not quite! We need to consider a couple caveats.
First, loadPyodide
takes awhile to execute (unfortunately, several seconds), so we'll need to call it asynchronously. We can handle this with async/await. Second, this function creates side effects. We'll need React's useEffect
hook, which is placed before the return
statement of a function component.
The effect will look something like this:
useEffect(() => {
;(async function () {
pyodide = await globalThis.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/dev/full/'
})
})()
}, [pyodide])
The await
expression gets wrapped inside an async
IIFE (Immediately Invoked Function Expression) that runs as soon as it's defined.
In addition, note the second argument of useEffect
, which is an array of the effect's dependencies. By default, an effect will run after every component render, but including an empty array []
of dependencies limits the effect to running only after a component mounts. Adding a dependency causes the effect to run again any time that value changes.
So far, our dependency list only includes the pyodide
variable we're using to store the result of loadPyodide
. However, you might have noticed that pyodide
hasn't actually been defined yet. As it turns out, we can't just add let pyodide
above our effect, since doing so would cause the value to be lost on every render. We need the value of pyodide
to persist across renders.
To accomplish this, we can use another hook, called useRef
, that stores our mutable value in the .current
property of a plain object, like so:
import { useEffect, useRef } from 'react'
export default function Pyodide() {
const pyodide = useRef(null)
useEffect(() => {
;(async function () {
pyodide.current = await globalThis.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/dev/full/'
})
})()
}, [pyodide])
// ...
}
The argument we pass into useRef
sets the initial value of pyodide.current
to null
. Notice that the pyodide
object itself is immutable: it never changes, even when we update the value of its .current
property. As a result, our effect only gets called once on component mount, which is exactly what we want.
Now we just need to figure out how to use the loaded Pyodide module to run Python code.
Step 3: Evaluate Python Code
Let's jump right into this one.
We'll use a function provided by Pyodide called runPython
to evaluate a string of Python code. For simplicity, we'll add everything to a new effect:
const [isPyodideLoading, setIsPyodideLoading] = useState(true)
const [pyodideOutput, setPyodideOutput] = useState(null)
useEffect(() => {
if (!isPyodideLoading) {
;(async function () {
setPyodideOutput(await pyodide.current.runPython(pythonCode))
})()
}
}, [isPyodideLoading, pyodide, pythonCode])
The first thing to notice is that we've added yet another hook, called useState
, which returns a pair of values. The first value is the current state, and the second is a function used to update the state with whatever value is passed as an argument. We also have the option to set the initial state by passing an argument to useState
.
Here we set the initial state of isPyodideLoading
to true
and add a condition inside the effect to call runPython
only when Pyodide is done loading. Just like with the first effect, we wrap runPython
inside an async
IIFE to await
the result. That result is then passed to setPyodideOutput
, which updates the variable pyodideOutput
from its initial value of null
.
This effect has three dependencies. As before, pyodide
remains constant, and therefore it will never cause our effect to rerun. We also expect the value of pythonCode
to remain unchanged, unless we decide to enable some sort of user input later on. Regardless, we have yet to actually declare this variable. Where should we do that?
Our string of pythonCode
is really the defining characteristic of the component. Thus, it makes sense to include pythonCode
in props
. Using the component would then look something like this:
<Pyodide pythonCode={myPythonCodeString} />
We need to consider isPyodideLoading
, too. This is a dependency we want updated: it should change from true
to false
once Pyodide is finished loading and ready to evaluate Python code. Doing so would re-render the component, run the effect, and meet the criteria of the if
statement in order to call runPython
. To accomplish this, we'll need to update the state with setIsPyodideLoading
inside our first effect.
Of course, we also need to render the results!
Complete React Component
Let's put all of it together as a complete, working component:
import { useEffect, useRef, useState } from 'react'
import Head from 'next/head'
export default function Pyodide({
pythonCode,
loadingMessage = 'loading...',
evaluatingMessage = 'evaluating...'
}) {
const indexURL = 'https://cdn.jsdelivr.net/pyodide/dev/full/'
const pyodide = useRef(null)
const [isPyodideLoading, setIsPyodideLoading] = useState(true)
const [pyodideOutput, setPyodideOutput] = useState(evaluatingMessage)
// load pyodide wasm module and initialize it
useEffect(() => {
;(async function () {
pyodide.current = await globalThis.loadPyodide({ indexURL })
setIsPyodideLoading(false)
})()
}, [pyodide])
// evaluate python code with pyodide and set output
useEffect(() => {
if (!isPyodideLoading) {
const evaluatePython = async (pyodide, pythonCode) => {
try {
return await pyodide.runPython(pythonCode)
} catch (error) {
console.error(error)
return 'Error evaluating Python code. See console for details.'
}
}
;(async function () {
setPyodideOutput(await evaluatePython(pyodide.current, pythonCode))
})()
}
}, [isPyodideLoading, pyodide, pythonCode])
return (
<>
<Head>
<script src={`${indexURL}pyodide.js`} />
</Head>
<div>
Pyodide Output: {isPyodideLoading ? loadingMessage : pyodideOutput}
</div>
</>
)
}
As promised, we now have pythonCode
included as one of the component's props
. We've also added setIsPyodideLoading
to the first effect, calling it inside the async
function after loadPyodide
resolves. Furthermore, we render pyodideOutput
inside a div
, which is wrapped within a React fragment underneath the Head
component. There are a few other additions to the code, as well. Let's go over them.
Our output is rendered conditionally. Initially, isPyodideLoading
is true
, so a loadingMessage
gets displayed. When isPyodideLoading
becomes false
, pyodideOutput
is shown instead. However, even though Pyodide has finished loading at this point, that doesn't mean runPython
is done evaluating code. We need an evaluatingMessage
in the meantime.
In many cases, this message will appear for only a fraction of a second, but for more complicated code it could hang around for much longer. To make it work, we've set evaluatingMessage
as the initial value of pyodideOutput
. A React component re-renders any time its state changes, so we can be sure all of our outputs get displayed as expected. Both messages have been added to props
with a default string value.
We've also encapsulated a bit of the second effect's contents inside an asynchronous function called evaluatePython
, which adds a try...catch
statement to handle any errors that might occur when calling runPython
.
Finally, we've added a variable called indexURL
so it can be updated easily if needed. Its value is passed to loadPyodide
and embedded in a template literal to build the full src
string of the script
tag.
Great! We've got a working Pyodide component. That's it, right?!? Well, no... Unfortunately, we have one final problem to solve.
One Final Problem: Multiple Components
If all you want is a single Pyodide component on your page, then you're good to go. However, if you're interested in multiple components per page, try it out. You'll get an error:
Uncaught (in promise) Error: Pyodide is already loading.
This error is a result of calling loadPyodide
more than once. If we want multiple components on a single web page, we'll need to figure out how to prevent all but the first component from initializing Pyodide. Unfortunately, Pyodide doesn't provide any method to tell whether loadPyodide
has already been called, so we have to find a way to share that information between components on our own.
React Context
Enter React context. This API allows us to share global data across components without having to deal with some external state management library. It works via the creation of a Context object, which comes with a special component called a Provider. The Provider gets wrapped around a high level component in the tree (usually the root of an application) and takes a value
prop to be passed along to child components that subscribe to it. In our case, we'll utilize the useContext
hook to listen for changes in the Provider's value
prop.
Alright, so we need to build a Provider component. We'll call it PyodideProvider
. Let's start by identifying the values that all of our lower-level Pyodide components will need to share.
Provider Component
Our goal is to ensure that only the first Pyodide component on a page calls loadPyodide
, so we know we'll need to create some condition in the first effect that depends on a shared value describing whether or not loadPyodide
has been called. Let's be explicit about it and call this value hasLoadPyodideBeenCalled
. It'll need to be a boolean that's initially set to false
, and then changed to true
. When does this change occur?
Well, since loadPyodide
is asynchronous, the update of hasLoadPyodideBeenCalled
must happen before calling loadPyodide
to be of any use. This is the reason why we do in fact need a new variable for our condition, rather than using isPyodideLoading
like in the second effect. We can't wait for Pyodide to load. Instead, the information must propagate immediately to our context value to keep subsequent components from running before they receive the update.
This need actually leads us to another, more subtle requirement for how we handle hasLoadPyodideBeenCalled
. The global values we define need to persist across component renders, meaning they'll have to be set with useRef
or useState
. Although useState
might seem like the natural option, it turns out this won't work. React doesn't guarantee immediate state updates. Instead, it batches multiple setState
calls asynchronously. Using state to handle our update to hasLoadPyodideBeenCalled
would likely be too slow to prevent later components from calling loadPyodide
more than once. Luckily, useRef
doesn't suffer from this latency: changes are reflected right away, so we'll use this hook instead.
Are there any other values that need to be shared globally? Yep! There are three more: pyodide
, isPyodideLoading
, and setIsPyodideLoading
.
Since loadPyodide
is now only being called a single time, it's also being assigned just once to pyodide.current
, the wasm module we want to share between all Pyodide components on a page. Furthermore, setIsPyodideLoading
gets called inside the first effect's condition, which again, only runs for the first component on the page. That function is paired with the state variable isPyodideLoading
, a value that, when updated, needs to trigger the second effect for every component. As a result, each of these variables needs to be shared globally via context.
Let's put it all together. Here's the complete Provider component:
import { createContext, useRef, useState } from 'react'
export const PyodideContext = createContext()
export default function PyodideProvider({ children }) {
const pyodide = useRef(null)
const hasLoadPyodideBeenCalled = useRef(false)
const [isPyodideLoading, setIsPyodideLoading] = useState(true)
return (
<PyodideContext.Provider
value={{
pyodide,
hasLoadPyodideBeenCalled,
isPyodideLoading,
setIsPyodideLoading
}}
>
{children}
</PyodideContext.Provider>
)
}
We first create and export a Context object called PyodideContext
using createContext
. Then we export our PyodideProvider
as default
, wrap PyodideContext.Provider
around any children
that may exist, and pass our global variables into the value
prop.
The Provider component can be imported wherever it's needed in the application. In Next.js, for example, wrapping PyodideProvider
around the application root happens in the _app.js
file and looks something like this:
import PyodideProvider from '../components/pyodide-provider'
export default function MyApp({ Component, pageProps }) {
return (
<PyodideProvider>
<Component {...pageProps} />
</PyodideProvider>
)
}
The Final Pyodide Component
At last, we're ready for the final Pyodide component, which can be included multiple times on a single page.
We only need to make a few adjustments to the original component. For starters, we'll have to import PyodideContext
from our Provider and extract the global values from it with useContext
. Then we update our first effect as described earlier to include hasLoadPyodideBeenCalled
.
Lastly, we add hasLoadPyodideBeenCalled
to the first effect's dependency list, along with setIsPyodideLoading
. Including the latter is necessary because, although React guarantees that setState
functions are stable and won't change on re-renders (which is why we could exclude it initially), we are now getting the value from useContext
. Since this context is defined in the Provider, our separate Pyodide component has no way of knowing that setIsPyodideLoading
is truly stable.
That's all of it! Here it is, the final Pyodide component:
import { useContext, useEffect, useState } from 'react'
import Head from 'next/head'
import { PyodideContext } from './pyodide-provider'
export default function Pyodide({
pythonCode,
loadingMessage = 'loading...',
evaluatingMessage = 'evaluating...'
}) {
const indexURL = 'https://cdn.jsdelivr.net/pyodide/dev/full/'
const {
pyodide,
hasLoadPyodideBeenCalled,
isPyodideLoading,
setIsPyodideLoading
} = useContext(PyodideContext)
const [pyodideOutput, setPyodideOutput] = useState(evaluatingMessage)
useEffect(() => {
if (!hasLoadPyodideBeenCalled.current) {
hasLoadPyodideBeenCalled.current = true
;(async function () {
pyodide.current = await globalThis.loadPyodide({ indexURL })
setIsPyodideLoading(false)
})()
}
}, [pyodide, hasLoadPyodideBeenCalled, setIsPyodideLoading])
useEffect(() => {
if (!isPyodideLoading) {
const evaluatePython = async (pyodide, pythonCode) => {
try {
return await pyodide.runPython(pythonCode)
} catch (error) {
console.error(error)
return 'Error evaluating Python code. See console for details.'
}
}
;(async function () {
setPyodideOutput(await evaluatePython(pyodide.current, pythonCode))
})()
}
}, [isPyodideLoading, pyodide, pythonCode])
return (
<>
<Head>
<script src={`${indexURL}pyodide.js`} />
</Head>
<div>
Pyodide Output: {isPyodideLoading ? loadingMessage : pyodideOutput}
</div>
</>
)
}
I've added both the Pyodide
React component and the Provider
to a Gist, as well. Feel free to view them here.
Top comments (1)
Thanks for the article. Having a issue. The Provider code isn't working for me. Didn't troubleshoot it too much. But trying to get the code to print out the result in the UI.
The correct answer 3+2 = 5 prints out in the console but is being returned as undefined here
"setPyodideOutput(await evaluatePython(pyodide.current, pythonCode));"
Any help would be great. Thank you
const pythonCode = 'print(3 + 2)';
const WasmPythonContainer = () => {
const classes = useStyles();
const indexURL = 'cdn.jsdelivr.net/pyodide/v0.19.1/f...';
const pyodide = React.useRef(null);
const hasLoadPyodideBeenCalled = React.useRef(false);
const [isPyodideLoading, setIsPyodideLoading] = React.useState(true);
const [pyodideOutput, setPyodideOutput] = React.useState('evaluating...');
// load pyodide wasm module and initialize it
React.useEffect(() => {
if (!hasLoadPyodideBeenCalled.current) {
// immediately set hasLoadPyodideBeenCalled ref, which is part of context, to true
// this prevents any additional Pyodide components from calling loadPyodide a second time
hasLoadPyodideBeenCalled.current = true;
(async function () {
pyodide.current = await window.loadPyodide({ indexURL });
// updating value of isPyodideLoading triggers second useEffect
setIsPyodideLoading(false);
})();
}
// pyodide and hasLoadPyodideBeenCalled are both refs and setIsPyodideLoading is a setState function (from context)
// as a result, these dependencies will be stable and never cause the component to re-render
}, [pyodide, hasLoadPyodideBeenCalled, setIsPyodideLoading]);
// evaluate python code with pyodide and set output
React.useEffect(() => {
if (!isPyodideLoading) {
const evaluatePython = async (pyodide: { runPython: (arg0: any) => any; }, pythonCode: string) => { // eslint-disable-line
try {
return pyodide.runPython(pythonCode);
} catch (error) {
console.error(error);
return 'Error evaluating Python code. See console for details.';
}
};
(async function () {
setPyodideOutput(await evaluatePython(pyodide.current, pythonCode));
})();
}
// component re-renders when isPyodideLoading changes, which is set with first useEffect and updated via context
}, [isPyodideLoading, pyodide, pythonCode]);
return (
Using WASM PYTHON
Pyodide Output: {isPyodideLoading ? 'loading...' : pyodideOutput}
);
};
export default WasmPythonContainer;