DEV Community

Brady Lambert
Brady Lambert

Posted on

Python in React with Pyodide

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:

  1. Include Pyodide.
  2. Set up the Python environment (load the Pyodide wasm module and initialize it).
  3. 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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/'
})
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

I've added both the Pyodide React component and the Provider to a Gist, as well. Feel free to view them here.

Discussion (0)