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
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âs where 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](https://reactjs.org/docs/hooks-effect.html)
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](https://reactjs.org/docs/hooks-reference.html#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](https://reactjs.org/docs/hooks-state.html)
, 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/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](https://reactjs.org/docs/hooks-reference.html#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({
id,
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)
// load pyodide wasm module and initialize it
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 globalThis.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
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))
})()
}
// component re-renders when isPyodideLoading changes, which is set with first useEffect and updated via context
}, [isPyodideLoading, pyodide, pythonCode])
return (
<>
<Head>
<script src={`${indexURL}pyodide.js`} />
</Head>
<div id={id}>
Pyodide Output: {isPyodideLoading ? loadingMessage : pyodideOutput}
</div>
</>
)
}
Iâve added both the Pyodide
and the PyodideProvider
React components to a Gist. Feel free to view them here. Iâve also published this article on dev.to.