Python and the Browser - Revisited

jennasys

JennaSys

Posted on April 10, 2021

Python and the Browser - Revisited

A while back, I posted about my initial foray into using Python to develop front-end web applications with React by using the Transcrypt transpiler.



Part of the initial learning process I went through was doing the official React tutorial, but using Python for the code instead of JavaScript. When I did that, I adhered to the structure of the application that was used in the tutorial pretty closely. Since then, I have been working with Transcrypt and React quite a bit more and have formed some of my own standards of practice for producing clean Python code in my React applications. In this post, I'll show you a few of those practices as I take the original class-based version of that program I did (which is what the React tutorial is based on), and convert it to use functional components and React hooks instead (which is all I use now).

Overview

The premise of the React tutorial is a Tic-Tac-Toe game that maintains a history of moves, where you can reset the board back to any previous point. The design consists of a Game component that manages the state of the game and holds the history of moves. Then there is a Board component that handles the rendering of the board. And Lastly, there is a Square component that renders a single square in the game.

My original version of the application has four files:

  • index.html (the application entry point and DOM root)
  • game.css (CSS selectors for the application)
  • tictacreact.py (the application code in Python)
  • pyreact.py (Python wrappers for the React.Component class and miscellaneous JavaScript functions)

Support Files

For this makeover, the CSS file and the index.html file will remain pretty much unchanged:

Listing 1: index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <title>Tic Tac React!</title>
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
    <script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
    <link rel="stylesheet" href="game.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="__target__/tictacreact.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Since we will no longer be using React class components, we can clean up the pyreact.py file quite a bit. I use this Python module to hold all of the Python-to-JavaScript mappings, that I can then import into other Python modules. This approach facilitates keeping any JavaScript messiness in one place and allows all of the other Python modules to remain pure Python, which keeps the Python linter happy.

For most projects, I've been using the Parcel bundler which has a Transcrypt plug-in available for it. With that, I would normally have a few lines in the pyreact.py module to load the React libraries with a JavaScript ES5 style import that uses the Node require() function like this:

React = require('react')
ReactDOM = require('react-dom')
Enter fullscreen mode Exit fullscreen mode

In this case, since we are loading the React libraries in the HTML header, the React and ReactDOM namespaces will be global, so I instead just stubbed out those libraries in the Python file.

Listing 2: pyreact.py

# __pragma__ ('skip')
"""
These JavaScript object stubs are just to
quiet the Python linter and are ignored by transcrypt as long
as they are imported inside of pragma skip/noskip lines.
"""

class React:
    createElement = None
    useState = None
    useEffect = None
    createContext = None
    useContext = None


class ReactDOM:
    render = None


class document:
    getElementById = None
    addEventListener = None
# __pragma__ ('noskip')


# Map React javaScript objects to Python identifiers
createElement = React.createElement
useState = React.useState
useEffect = React.useEffect
createContext = React.createContext
useContext = React.useContext


# Wrap the ReactDOM.render method to hide JavaScript details
def render(root_component, props, container):
    def main():
        ReactDOM.render(
            React.createElement(root_component, props),
            document.getElementById(container)
        )

    document.addEventListener("DOMContentLoaded", main)
Enter fullscreen mode Exit fullscreen mode

The section between the skip/noskip pragma lines isn't really needed other than to quiet the Python linter not being able to resolve the JavaScript object names. They are just Python stub declarations that will ultimately be ignored by Transcrypt thanks to the compiler directives.

The mappings in this file are where Transcrypt does a lot of its magic. I'm basically assigning a JavaScript object to a Python variable. From there, it can be used just like any other Python object. It can be imported into other Python modules, and its methods can be called. Even though I'm using JavaScript libraries, I only need to know the library's API to code to it using Python.

The render() function doesn't change from before, and is just a wrapper around the ReactDOM.render() method that lets us encapsulate the JavaScript calls that go along with it.

The Refactoring

Most of the actual refactoring we did in this version of the application was in the tictacreact.py module. Beyond just turning the class components into functional components, we also changed how some of the state gets updated. While it didn't save us many lines of code, it is now a bit more modularized and (hopefully) more readable than what was there before.

Listing 3: tictacreact.py

from pyreact import render, useState, createElement as el
from pyreact import createContext, useContext


Ctx = createContext()


def Square(props):
    idx = props['idx']

    ctx = useContext(Ctx)
    squares = ctx['squares']
    onClick = ctx['onClick']

    return el('button', {'className': 'square',
                         'onClick': lambda: onClick(idx)
                         }, squares[idx])


def Row(props):
    rowNum = props['rowNum']

    row = [el(Square, {'idx': (rowNum * 3) + col_num}) for col_num in range(3)]
    return el('div', {'className': 'board-row'}, row)


def Board():
    rows = [el(Row, {'rowNum': row_num}) for row_num in range(3)]
    return el('div', None, rows)


def Moves(props):
    numMoves = props['numMoves']
    setStepNumber = props['setStepNumber']

    def get_move(move):
        desc = ('Go to move #' + str(move)) if move > 0 else 'Go to game start'
        return el('li', {'key': move},
                  el('button', {'className': 'move-history',
                                'onClick': lambda: setStepNumber(move)
                                }, desc)
                  )

    return [get_move(move) for move in range(numMoves)]


def Game():
    history, setHistory = useState([{'squares': [None for _ in range(9)]}])
    stepNumber, setStepNumber = useState(0)

    board = history[stepNumber]
    xIsNext = (stepNumber % 2) == 0
    winner = calculate_winner(board['squares'])

    if winner is not None:
        status = f"Winner: {winner}"
    elif stepNumber == 9:
        status = "No Winner"
    else:
        status = f"Next player: {'X' if xIsNext else 'O'}"

    def handle_click(i):
        new_squares = list(board['squares'])
        if winner or new_squares[i]:  # Already winner or square not empty
            return  # Nothing to do

        new_squares[i] = 'X' if xIsNext else 'O'

        tmp_history = history[:stepNumber + 1]  # Slice in case step changed
        new_history = [{'squares': move['squares']} for move in tmp_history]
        new_history.append({'squares': new_squares})
        setHistory(new_history)
        setStepNumber(len(new_history) - 1)

    return el(Ctx.Provider, {'value': {'squares': board['squares'],
                                       'onClick': handle_click}
                             },
              el('div', {'className': 'game'},
                 el('div', {'className': 'game-board'},
                    el(Board, None),
                    el('div', {'className': 'game-status'}, status),
                    ),
                 el('div', {'className': 'game-info'}, 'Move History',
                    el('ol', None,
                       el(Moves, {'numMoves': len(history),
                                  'setStepNumber': setStepNumber}
                          )
                       )
                    )
                 )
              )


# Render the component in a 'container' div
render(Game, None, 'root')


def calculate_winner(squares):
    lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
    ]

    for line in lines:
        a, b, c = line
        if squares[a] and (squares[a] == squares[b]) and (squares[a] == squares[c]):
            return squares[a]
    return None
Enter fullscreen mode Exit fullscreen mode

In the main Game component, we made several changes starting with converting the class-based state object to individual useState() hooks instead. So the history and stepNumber state variables now have their own companion update functions.

Since the xIsNext state variable that was being used before is just calculated based on the value of another state variable, I changed it to be a local variable instead. It will get recalculated if a re-render happens due to a change in the state that it is based on.

To clarify what is being displayed at any given time, I also added the local variable board to hold the current board values as a convenience. Now, as determined by the stepNumber, we pull it out of the history list just once instead of every time we need to use it as we were doing before. This value also gets recalculated when a re-render happens.

The handle_click() function gets cleaned up a little, but it is still a bit busy since we need to make copies of the history and board squares in order to update them since we are working with immutable objects. Until Transcrypt adds the Python copy standard library to what it supports, you either have to use a JavaScript function to do that, or do it manually as we did here for history where we used a list comprehension:

new_history  = [{'squares': move['squares']} for  move  in  tmp_history]
Enter fullscreen mode Exit fullscreen mode

For the list of previous moves that get displayed in the UI, instead of generating the list item elements in the Game component, we moved that functionality into its own Moves component. This change cleans up the Game component and makes the overall application structure a little more readable.

One of the practices I started doing was to deconstruct the values held in the props object into local variables rather than directly referencing them from props just when they are needed:

def Moves(props):
    numMoves = props['numMoves']
    setStepNumber = props['setStepNumber']
Enter fullscreen mode Exit fullscreen mode

This practice accomplishes two things. First, by deconstructing all of the values right at the top of the function definition, I know exactly what props that component is expecting without having to search the entire function to figure it out. Second, it cleans up the code in the function where I actually use those values by not having to do the dictionary lookups in place.

The last somewhat subtle change we made is to put the handle_click() function and the board squares into a context variable:

el(Ctx.Provider, {'value': {'squares': board['squares'],
                            'onClick': handle_click}
                            },
Enter fullscreen mode Exit fullscreen mode

Using this context variable saves us from having to pass these values down through several other layers of components that don't need them, just so that we can use them in the Square component.

In the Board component, we really cleaned it up in that it now just returns a div element with three Row components. And since we are now using the context variable, we no longer need to pass any props into it.

The Row component is something new we added with this refactor that clarifies conceptually what is being generated. Similar to the Board component, the Row component returns a div element containing just three Square components.

The Square component is now a bonafide React component instead of just an imperative function. Functionally it is the same as before, but we did add in the React useContext() hook to pull out the values we needed to use here:

ctx = useContext(Ctx)
squares = ctx['squares']
onClick = ctx['onClick']
Enter fullscreen mode Exit fullscreen mode

Finally, we just made some minor optimizations to the calculate_winner() function from the earlier version.

Transpile & Run

Right now, Transcrypt version 3.7.16 only works with Python 3.6 or 3.7, so in setting up a virtual environment, I'll use this:

$ python3.7 -m venv venv

then activate it:

$ source ./venv/bin/activate

(for Windows use venv\Scripts\activate )

and then install Transcrypt:

(venv) $ pip install transcrypt

To build the application, you just need to give Transcrypt the entry point of your application, and it will walk the dependency tree to transpile any other related modules:

(venv) $ transcrypt --nomin --build --map tictacreact

We also gave it a few CLI options:

  • nomin - tells it not to minify the generated JavaScript (Note: The Java runtime is needed for this to work)
  • build - tells it to start from scratch
  • map - tells it to generate a JavaScript-to-Python source code map file

Once that is done, we need to serve up the generated files before we can open up the application in a web browser. A quick way to do this is using the HTTP server that comes with Python:

(venv) $ python -m http.server

Then open the application:

http://localhost:8000/index.html

 
Screenshot

You can find all of the source code for this application here:

https://github.com/JennaSys/tictacreact2

A live demo of this code (with source maps) is also hosted on GitHub Pages:

https://jennasys.github.io/tictacreact2/

Conclusion

As someone that really likes Python and isn't a big fan of JavaScript, using Transcrypt to develop React applications with Python has been working out decidedly well for me so far. To share how I was doing it, I had started putting together an outline for a talk I was going to give at my Python meetup group. As it turned out, that outline kept growing, and I ended up writing an entire book about it instead. If you're interested, you can find out more about the "React to Python" book here: https://pyreact.com

💖 💪 🙅 🚩
jennasys
JennaSys

Posted on April 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related