33
loading...
This website collects cookies to deliver better user experience
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.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>
)
}
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/'
})
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.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.useEffect(() => {
;(async function () {
pyodide = await globalThis.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/dev/full/'
})
})()
}, [pyodide])
await
expression gets wrapped inside an async
IIFE (Immediately Invoked Function Expression) that runs as soon as it's defined.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.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.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])
// ...
}
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.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])
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
.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
.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?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} />
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.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>
</>
)
}
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.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.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.evaluatePython
, which adds a try...catch
statement to handle any errors that might occur when calling runPython
.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.Uncaught (in promise) Error: Pyodide is already loading.
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.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.PyodideProvider
. Let's start by identifying the values that all of our lower-level Pyodide components will need to share.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?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.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.pyodide
, isPyodideLoading
, and setIsPyodideLoading
.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.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>
)
}
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.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>
)
}
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
.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.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>
</>
)
}
Pyodide
React component and the Provider
to a Gist, as well. Feel free to view them here.