Skip to content

Tutorial: Tic-Tac-Toe

Overview

You will build a small tic-tac-toe game. This tutorial assumes no prior ReactPy knowledge, and the techniques it covers — components, props, state, and lifting state up — are the ones you will use in every app you write.

Note

This tutorial is for people who prefer to learn by doing. If you would rather meet each concept one at a time, start with Your First Component instead.

The tutorial is divided into four sections:

What are you building?

You will build an interactive tic-tac-toe game. Here is the finished result:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
from reactpy import component, html, use_state


@component
def square(value, on_click):
    return html.button(
        {"className": "square", "onClick": on_click},
        value,
    )


@component
def board(x_is_next, squares, on_play):
    def handle_click(i):
        def inner(event):
            """
            Due to a quirk of Python, if your event handler needs args other than
            `event`, you will need to create a wrapper function as seen above.
            Ref: https://pylint.readthedocs.io/en/stable/user_guide/messages/warning/cell-var-from-loop.html
            """
            if calculate_winner(squares) or squares[i]:
                return

            next_squares = squares.copy()
            next_squares[i] = "X" if x_is_next else "O"
            on_play(next_squares)

        return inner

    winner = calculate_winner(squares)
    status = (
        f"Winner: {winner}" if winner else "Next player: " + ("X" if x_is_next else "O")
    )

    return html.fragment(
        html.div({"className": "status"}, status),
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )


@component
def game():
    history, set_history = use_state([[None] * 9])
    current_move, set_current_move = use_state(0)
    x_is_next = current_move % 2 == 0
    current_squares = history[current_move]

    def handle_play(next_squares):
        next_history = history[: current_move + 1]
        next_history.append(next_squares)
        set_history(next_history)
        set_current_move(len(next_history) - 1)

    def jump_to(next_move):
        return lambda _event: set_current_move(next_move)

    moves = []
    for move, _squares in enumerate(history):
        description = f"Go to move #{move}" if move > 0 else "Go to game start"

        moves.append(
            html.li(
                {"key": move},
                html.button({"onClick": jump_to(move)}, description),
            )
        )

    return html.div(
        {"className": "game"},
        html.div(
            {"className": "game-board"},
            board(x_is_next, current_squares, handle_play),
        ),
        html.div({"className": "game-info"}, html.ol(moves)),
    )


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 not squares:
            continue
        if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]:
            return squares[a]

    return None
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

If the code does not make sense yet, do not worry — that is the point of the tutorial. Play the game before you read it. You will notice a list to the right of the board that records every move and lets you jump back to any of them. That list is what the last section builds toward.

Note

Throughout this tutorial, app.py is the file you edit, and the styles.css tab shows the stylesheet it relies on. ReactPy serves your components, not files on your disk, so the stylesheet is loaded into the page in your ASGI entry module. Setup for the tutorial shows how.

Setup for the tutorial

The code below is your starting point: a single square, with no interactivity.

1
2
3
@component
def square():
    return html.button({"className": "square"}, "X")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

It should render one empty-looking square containing an X.

Note

You can follow along in your own editor instead. You will need:

  1. Python 3.11 or newer, with pip install reactpy[asgi] uvicorn[standard]
  2. A file app.py containing the example above
  3. A file styles.css containing the stylesheet above
  4. A file main.py that mounts app.py and injects the stylesheet:

    from pathlib import Path
    
    from app import square
    
    from reactpy import html
    from reactpy.executors.asgi import ReactPy
    
    # ReactPy serves the components, not the files on your disk, so the stylesheet
    # is read here and injected into the page's <head>.
    CSS = Path("styles.css").read_text()
    
    my_app = ReactPy(square, html_head=html.head(html.style(CSS)))
    
  5. uvicorn main:my_app, then open the URL it prints

If the setup fights you, do not let it stop you — follow along online and try the local setup again later. See Creating a Standalone React App for the details.

The basics: components, props, and state

Inspecting the starter code

The example defines a component. In ReactPy, a component is a function that returns what part of the interface should look like. Read it line by line:

@component
def square():
    return html.button({"className": "square"}, "X")

The first two lines are boilerplate: @component turns the function below it into a component, and html gives you a function for every HTML tag.

The def square(): line defines a function called square. Unlike React, ReactPy component names are snake_case and lowercase — there is no capital-letter rule, because ReactPy tells a component from an HTML tag by whether you call it, not by how it is spelled.

The return line gives back a button. html.button(...) is a call to a function, not an element literal: children come after the attribute dictionary as positional arguments, so "X" is the text inside the button. {"className": "square"} is that attribute dictionary, and className is the DOM attribute name — attribute keys are forwarded to the browser verbatim, so they keep React's camelCase spelling.

Building the board

The board needs nine squares, not one. Copying the button twice is tempting:

@component
def square():
    return html.button({"className": "square"}, "X"), html.button({"className": "square"}, "X")

That does not fail the way React's "adjacent JSX elements" error does. The comma builds a two-item tuple; ReactPy does not know you meant two children, coerces it with str(), and the page shows the VDOM dictionaries as literal text:

1
({'children': ['X'], 'attributes': {'className': 'square'}, 'tagName': 'button'}, ...)

Verified through Layout.render(): the model's only child is that string. If the page ever shows you a Python repr, a missing call or a stray comma is the usual cause. A component returns exactly one thing. To return several children, wrap them in a fragment with html.fragment(...), which groups children without adding an element of its own:

@component
def square():
    return html.fragment(
        html.button({"className": "square"}, "X"),
        html.button({"className": "square"}, "X"),
    )

Now copy-paste your way to nine squares and... they come out in a single line, not a grid. Group them into rows with divs carrying the className the stylesheet already defines, and number the squares so you can tell them apart:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@component
def board():
    return html.fragment(
        html.div(
            {"className": "board-row"},
            html.button({"className": "square"}, "1"),
            html.button({"className": "square"}, "2"),
            html.button({"className": "square"}, "3"),
        ),
        html.div(
            {"className": "board-row"},
            html.button({"className": "square"}, "4"),
            html.button({"className": "square"}, "5"),
            html.button({"className": "square"}, "6"),
        ),
        html.div(
            {"className": "board-row"},
            html.button({"className": "square"}, "7"),
            html.button({"className": "square"}, "8"),
            html.button({"className": "square"}, "9"),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

You now have a tic-tac-toe board. But you have a naming problem: the component is called square and renders a whole board. Rename it to board.

At this point your code should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@component
def board():
    return html.fragment(
        html.div(
            {"className": "board-row"},
            html.button({"className": "square"}, "1"),
            html.button({"className": "square"}, "2"),
            html.button({"className": "square"}, "3"),
        ),
        html.div(
            {"className": "board-row"},
            html.button({"className": "square"}, "4"),
            html.button({"className": "square"}, "5"),
            html.button({"className": "square"}, "6"),
        ),
        html.div(
            {"className": "board-row"},
            html.button({"className": "square"}, "7"),
            html.button({"className": "square"}, "8"),
            html.button({"className": "square"}, "9"),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Note

That is a lot of typing. Copying code from this page is fine, but you will learn more by typing each snippet at least once yourself.

Passing data through props

Nine hardcoded buttons means nine places to change when a square's content becomes dynamic. Instead, extract a square component and give it the value to display — a prop:

@component
def square(value):
    return html.button({"className": "square"}, value)

There is no JSX, so props are simply Python parameters. Call the component and pass the argument:

square("A")

Update board to call square nine times, passing a different letter each time so you can see the values arrive:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@component
def square(value):
    return html.button({"className": "square"}, value)


@component
def board():
    return html.fragment(
        html.div(
            {"className": "board-row"},
            square("A"),
            square("B"),
            square("C"),
        ),
        html.div(
            {"className": "board-row"},
            square("D"),
            square("E"),
            square("F"),
        ),
        html.div(
            {"className": "board-row"},
            square("G"),
            square("H"),
            square("I"),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Notice how board and square are both called the same way, with no ceremony, while html.div and html.button are calls into the html object. Components are just functions you wrote; tags are functions ReactPy provides.

Props are read-only. square cannot assign to value to change what its parent passed, and ReactPy has no mechanism to help it try. Data flows down; to change it, the component that owns it must change it.

Making an interactive component

Now make a square fill itself when clicked. Add a handler inside square and register it under onClick:

@component
def square():
    def handle_click(event):
        print("clicked!")

    return html.button({"className": "square", "onClick": handle_click})

A handler is any callable, and ReactPy calls it with exactly one argument — the event payload. Name it event, even if you ignore it.

Unlike React, there is no browser console to open: ReactPy components run on the server, so print lands in the terminal running your app, next to the URL. Click a square several times and you will see the line repeat there.

Pitfall

The key must be camelCase onClick. The React-looking on_click is accepted and shipped to the browser like any other on* key — and then nothing listens for it, because no browser event is named on_click. Verified: the server-side model holds a registered on_click handler while clicking the button leaves the component's state at 0. The button renders, looks normal, and never fires, with no error and no log line anywhere. If a control does nothing, check the spelling of the key first. See Responding to Events.

Next, make the square remember that it was clicked. ReactPy gives components memory with the use_state hook, which returns the current value and the function that changes it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# None is what an unfilled square holds; ReactPy renders None as nothing.
EMPTY_SQUARE: str | None = None


@component
def square():
    value, set_value = use_state(EMPTY_SQUARE)

    def handle_click(event):
        set_value("X")

    return html.button({"className": "square", "onClick": handle_click}, value)


@component
def board():
    return html.fragment(
        html.div(
            {"className": "board-row"},
            square(),
            square(),
            square(),
        ),
        html.div(
            {"className": "board-row"},
            square(),
            square(),
            square(),
        ),
        html.div(
            {"className": "board-row"},
            square(),
            square(),
            square(),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

value, set_value = use_state(EMPTY_SQUARE) unpacks a pair: the state you read and the setter you call. Its argument is the starting value, used only on the component's first render. Calling set_value asks ReactPy to render the component again, and that render sees the new value.

The value is None, meaning "empty", and ReactPy renders None as nothing, so a fresh board is blank. It lives in a module-level constant annotated str | None, which is what lets a type checker accept both None and "X" in that square — without it, the type is inferred from None alone and assigning a mark is flagged. See Use State.

Note that ReactPy renders True and False as the words True and False, so None is the only value that disappears — React's habit of writing cond && <X/> has no working Python equivalent. See Conditional Rendering.

Click each square and watch them fill independently. Nine square() calls contain nine separate use_state calls, and each one keeps its own value.

That is a defect, not a feature. Each square's value lives inside that square, so no other part of the program can see the whole board — which means nothing can answer "has someone won?". The next section fixes this by moving the state to where the whole board is visible.

Completing the game

Right now the game has a serious limitation: nobody can win.

Lifting state up

To detect a winner you need the whole board in one place. The fix is to move the state out of square and into board, which already renders all nine squares — a move called lifting state up.

board owns a list of nine values and passes each one down:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@component
def square(value, on_click):
    return html.button({"className": "square", "onClick": on_click}, value)


@component
def board():
    initial_board: list[str | None] = [None] * 9
    squares, set_squares = use_state(initial_board)

    def handle_click(i):
        def inner(event):
            if squares[i]:
                return
            next_squares = squares.copy()
            next_squares[i] = "X"
            set_squares(next_squares)

        return inner

    return html.fragment(
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

square is now stateless: it takes value to display and on_click to call, and remembers nothing. That is the shape of most components you will write — they take data in and report events out, and the state lives above them.

Note the spelling distinction, because both forms are correct in different places. on_click above is a Python parameter — a prop name you invented, so it is snake_case like every other identifier you define. onClick is the attribute dictionary key the browser needs, so it keeps its camelCase spelling. Naming a prop onClick or writing the attribute key on_click are both mistakes, in opposite directions.

Two details are worth pausing on.

First, the handler needs to know which square was clicked, but ReactPy hands a handler exactly one argument: the event. The usual answer is a factory, as handle_click(i) does here — it returns the handler that closes over i. You will also see this written as a lambda:

html.button({"onClick": lambda event: handle_click(i)}, squares[i])

That works when written out per square. It has a sharp edge inside a loop, which Picking a key demonstrates.

Second, board renders three rows by hand, so square(...) appears nine times with nine different indices. A loop is shorter, and once you write one you will need keys — see Rendering Lists.

Why immutability is important

Look closely at what the handler does before it calls the setter:

next_squares = squares.copy()
next_squares[i] = "X"
set_squares(next_squares)

It copies the list, changes the copy, and hands the copy to the setter. There are two ways to change data: mutate it in place, or replace it with a modified copy. The obvious Python code takes the first route, and it fails in a way that is hard to diagnose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@component
def board():
    initial_board: list[str | None] = [None] * 9
    squares, set_squares = use_state(initial_board)

    def handle_click(i):
        # Mutating in place and handing back the same object: ReactPy compares
        # the new value with the old one, sees the same object, and schedules no
        # render at all. The button looks fine and never responds.
        def inner(event):
            squares[i] = "X"
            set_squares(squares)

        return inner

    # start
    return html.fragment(
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Click a square. Nothing happens — no error, no log line, no warning. The handler runs; the board just never changes.

Verified against this example: after three clicks on that square, the board is still nine empty cells. The cause is that set_squares(squares) passes back the very same object that is already in state. ReactPy compares the new value against the current one, finds them equal, and skips the render — an optimisation that turns an in-place mutation into a silent no-op.

Pitfall

Mutate-then-set-the-same-object is the most common ReactPy bug and the quietest one. squares[i] = "X" followed by set_squares(squares) schedules no render at all. Neither does mutating without calling the setter. Always build a new list — squares.copy(), a list comprehension, or slicing — and pass that. See Updating Arrays in State.

Immutability earns its keep twice here. It is what makes the board update at all, and it is what lets the last section keep every past move around: because old lists are never modified, they stay valid and can be re-displayed later.

Deep Dive

React's version of this section argues that immutability lets React skip re-rendering parts of the tree that did not change. That argument does not apply to ReactPy: there is no memo and no render bailout, so a child component's body re-runs whenever its parent renders, even with identical props. Immutable data is still required — for the reason above, and so equality checks mean what you expect — but do not expect it to save you renders. See You Might Not Need an Effect.

Taking turns

Players can fill squares, but only with X. Add a second piece of state to board for whose turn it is, and flip it after every move:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@component
def board():
    initial_board: list[str | None] = [None] * 9
    squares, set_squares = use_state(initial_board)
    x_is_next, set_x_is_next = use_state(True)

    def handle_click(i):
        def inner(event):
            next_squares = squares.copy()
            next_squares[i] = "X" if x_is_next else "O"
            set_squares(next_squares)
            set_x_is_next(not x_is_next)

        return inner

    status = "Next player: " + ("X" if x_is_next else "O")

    return html.fragment(
        html.div({"className": "status"}, status),
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Squares now alternate X and O, and the status line above the board tracks the next player.

One defect remains: click a filled square and the other player overwrites it. That happens because the handler never checks whether the square is already taken. Return early instead of playing over it:

def inner(event):
    if squares[i]:
        return
    next_squares = squares.copy()
    ...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@component
def board():
    initial_board: list[str | None] = [None] * 9
    squares, set_squares = use_state(initial_board)
    x_is_next, set_x_is_next = use_state(True)

    def handle_click(i):
        def inner(event):
            if squares[i]:
                return
            next_squares = squares.copy()
            next_squares[i] = "X" if x_is_next else "O"
            set_squares(next_squares)
            set_x_is_next(not x_is_next)

        return inner

    status = "Next player: " + ("X" if x_is_next else "O")

    return html.fragment(
        html.div({"className": "status"}, status),
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Verified: clicking square 0 twice leaves X in place rather than replacing it with O, and the other squares still play.

The guard is the first line of the handler, before any state is built or set. Putting it after set_squares would let the invalid move through and try to undo it — an expensive, error-prone shape. Return early, then proceed.

Declaring a winner

Add a helper that checks the eight winning lines. It knows nothing about ReactPy, so read it as plain Python:

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 a, b, c in lines:
        if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]:
            return squares[a]
    return None

It returns "X", "O", or None. Where you put it in the file does not matter; keeping it at the bottom means you do not scroll past it every time.

Call it in two places — to stop play after a win, and to write the status line:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@component
def board():
    initial_board: list[str | None] = [None] * 9
    squares, set_squares = use_state(initial_board)
    x_is_next, set_x_is_next = use_state(True)

    def handle_click(i):
        def inner(event):
            if calculate_winner(squares) or squares[i]:
                return
            next_squares = squares.copy()
            next_squares[i] = "X" if x_is_next else "O"
            set_squares(next_squares)
            set_x_is_next(not x_is_next)

        return inner

    winner = calculate_winner(squares)
    status = (
        f"Winner: {winner}" if winner else "Next player: " + ("X" if x_is_next else "O")
    )

    return html.fragment(
        html.div({"className": "status"}, status),
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )


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 a, b, c in lines:
        if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]:
            return squares[a]
    return None
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Get three in a row and the status changes to Winner: X, after which no square responds — the guard returns early because calculate_winner(squares) is truthy.

Notice that the winner is not stored in state. It is computed from squares during every render, which is both simpler and impossible to get out of sync. If a value can be derived from state you are already storing, do not add a second piece of state for it: two sources of truth drift, and one number cannot. See Reacting to Input with State.

The game is playable. What it cannot do is show you how it got here — which is where immutability finally pays off.

Adding time travel

Because every move produced a new list instead of modifying the old one, the previous boards still exist. All that is left is to keep them.

The history is a list of boards, one per move:

[
    # before the first move
    [None] * 9,
    # after the first move
    [None, None, None, None, "X", None, None, None, None],
    # after the second move
    [None, None, None, None, "X", None, None, None, "O"],
]

Lifting state up, again

Add a game component above board to hold that history. It will also decide which move you are looking at, so board must stop owning state and start receiving what to draw.

Start by having game render board inside the layout the stylesheet expects, and keep the history and the current move as its own state. board keeps its own handle_click, but now reports the finished move upward through an on_play prop instead of storing it:

@component
def game():
    history, set_history = use_state([[None] * 9])

    def handle_play(next_squares):
        set_history([*history, next_squares])

    current_move = len(history) - 1
    x_is_next = current_move % 2 == 0
    current_squares = history[current_move]

    return html.div(
        {"className": "game"},
        html.div(
            {"className": "game-board"},
            board(x_is_next, current_squares, handle_play),
        ),
        html.div({"className": "game-info"}, html.ol()),
    )

x_is_next and current_squares are not state. They are computed from history during the render, which is why they cannot drift out of sync with it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@component
def game():
    history, set_history = use_state([[None] * 9])

    def handle_play(next_squares):
        set_history([*history, next_squares])

    current_move = len(history) - 1
    x_is_next = current_move % 2 == 0
    current_squares = history[current_move]

    return html.div(
        {"className": "game"},
        html.div(
            {"className": "game-board"},
            board(x_is_next, current_squares, handle_play),
        ),
        html.div({"className": "game-info"}, html.ol()),
    )


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 a, b, c in lines:
        if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]:
            return squares[a]
    return None
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

The game plays exactly as it did before — only now the whole history lives in one component that can see all of it.

Showing the past moves

You have a list of moves in state; render it as a list of buttons. Where React reaches for map, Python reaches for a comprehension or a loop:

moves = []
for move in range(len(history)):
    description = f"Go to move #{move}" if move > 0 else "Go to game start"
    moves.append(html.li(html.button({"onClick": jump_to(move)}, description)))

Then render html.ol(moves) inside the game-info div. Add a current_move state variable and an empty jump_to(move) that does nothing yet, so the list has somewhere to go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@component
def game():
    history, set_history = use_state([[None] * 9])
    current_move, set_current_move = use_state(0)

    def handle_play(next_squares):
        next_history = deepcopy(history[: current_move + 1])
        next_history.append(next_squares)
        set_history(next_history)
        set_current_move(len(next_history) - 1)

    def jump_to(move):
        def inner(event):
            # TODO: make the board show move
            pass

        return inner

    x_is_next = current_move % 2 == 0
    current_squares = history[current_move]

    moves = []
    for move in range(len(history)):
        description = f"Go to move #{move}" if move > 0 else "Go to game start"
        moves.append(html.li(html.button({"onClick": jump_to(move)}, description)))

    return html.div(
        {"className": "game"},
        html.div(
            {"className": "game-board"},
            board(x_is_next, current_squares, handle_play),
        ),
        html.div({"className": "game-info"}, html.ol(moves)),
    )


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 a, b, c in lines:
        if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]:
            return squares[a]
    return None
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Make a few moves and the list grows: Go to game start, Go to move #1, Go to move #2. Clicking one does nothing, because jump_to is still the placeholder — that is the next step.

Pitfall

With REACTPY_DEBUG on, the server logs Key not specified for child in list ... for every row. ReactPy is telling you the same thing React does: a list you will re-render needs a key on each item. It is a warning, not an error, so a list can ship without one — and misbehave later in ways that are hard to trace back. The fix is Picking a key.

A related trap: pass a list, not a generator. html.ol(html.li(...) for ... in ...) skips the key check entirely, with the warning Did not verify key-path integrity of children in generator ... - pass a sequence, and an iterator stored in state is worse still — it is single-use, so the first render shows the rows and every later render shows nothing.

Picking a key

When you re-render a list, ReactPy has to work out which item is which. It cannot read your intent, so you tell it: give each item a key.

html.li({"key": move}, html.button({"onClick": jump_to(move)}, description))

key is a reserved name, and it is spelled two different ways depending on what you are creating — mixing them up is a hard error:

You are creating Spell it Passing it the other way raises
an element inside the attribute dict: html.li({"key": move}, ...) TypeError: Vdom.__call__() got an unexpected keyword argument 'key'
a component as a keyword: row(item, key=item["id"]) silently meaningless, since the dict is the attribute dict

A key identifies an item across renders. Change a component's key and ReactPy destroys it and builds a fresh one, discarding its state — which is a useful trick on its own, and the subject of Preserving and Resetting State. Keys need only be unique among siblings, not globally.

For the move list, the move's index is a legitimate key: moves are never reordered, inserted in the middle, or deleted, so index and identity agree. That is the exception. When items can be added or removed, the array index stops identifying anything, and state quietly lands on the wrong row — use a real identifier instead. See Rendering Lists.

Implementing time travel

jump_to(move) only has to say which move you are looking at:

def jump_to(move):
    def inner(event):
        set_current_move(move)

    return inner

And the values derived during render must follow current_move rather than the end of the list:

x_is_next = current_move % 2 == 0
current_squares = history[current_move]

Playing a move now has one more rule. If you jump back and play from there, the moves after that point are no longer the future — they are discarded:

def handle_play(next_squares):
    next_history = history[: current_move + 1]
    next_history.append(next_squares)
    set_history(next_history)
    set_current_move(len(next_history) - 1)

The slice history[: current_move + 1] keeps everything up to and including the move you jumped back to, dropping the rest, and set_current_move puts you at the new end.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
from reactpy import component, html, use_state


@component
def square(value, on_click):
    return html.button(
        {"className": "square", "onClick": on_click},
        value,
    )


@component
def board(x_is_next, squares, on_play):
    def handle_click(i):
        def inner(event):
            """
            Due to a quirk of Python, if your event handler needs args other than
            `event`, you will need to create a wrapper function as seen above.
            Ref: https://pylint.readthedocs.io/en/stable/user_guide/messages/warning/cell-var-from-loop.html
            """
            if calculate_winner(squares) or squares[i]:
                return

            next_squares = squares.copy()
            next_squares[i] = "X" if x_is_next else "O"
            on_play(next_squares)

        return inner

    winner = calculate_winner(squares)
    status = (
        f"Winner: {winner}" if winner else "Next player: " + ("X" if x_is_next else "O")
    )

    return html.fragment(
        html.div({"className": "status"}, status),
        html.div(
            {"className": "board-row"},
            square(squares[0], handle_click(0)),
            square(squares[1], handle_click(1)),
            square(squares[2], handle_click(2)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[3], handle_click(3)),
            square(squares[4], handle_click(4)),
            square(squares[5], handle_click(5)),
        ),
        html.div(
            {"className": "board-row"},
            square(squares[6], handle_click(6)),
            square(squares[7], handle_click(7)),
            square(squares[8], handle_click(8)),
        ),
    )


@component
def game():
    history, set_history = use_state([[None] * 9])
    current_move, set_current_move = use_state(0)
    x_is_next = current_move % 2 == 0
    current_squares = history[current_move]

    def handle_play(next_squares):
        next_history = history[: current_move + 1]
        next_history.append(next_squares)
        set_history(next_history)
        set_current_move(len(next_history) - 1)

    def jump_to(next_move):
        return lambda _event: set_current_move(next_move)

    moves = []
    for move, _squares in enumerate(history):
        description = f"Go to move #{move}" if move > 0 else "Go to game start"

        moves.append(
            html.li(
                {"key": move},
                html.button({"onClick": jump_to(move)}, description),
            )
        )

    return html.div(
        {"className": "game"},
        html.div(
            {"className": "game-board"},
            board(x_is_next, current_squares, handle_play),
        ),
        html.div({"className": "game-info"}, html.ol(moves)),
    )


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 not squares:
            continue
        if squares[a] and squares[a] == squares[b] and squares[a] == squares[c]:
            return squares[a]

    return None
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
}

.square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
}

.board-row:after {
    clear: both;
    content: "";
    display: table;
}

.status {
    margin-bottom: 10px;
}
.game {
    display: flex;
    flex-direction: row;
}

.game-info {
    margin-left: 20px;
}
1
# TODO

Verified against this file: play three moves to put X in the corners, click Go to game start, and the board is empty again with Next player: X. Jump to Go to move #1, play a different square, and the list retracts to two entries — the abandoned branch is gone.

None of that would work if the handlers mutated squares in place. Every stored board would be the same list, and rewinding would show the current game. This is the payoff for the rule from Why immutability is important.

Pitfall

Do not store one board and a separate list of moves that points into it. The history must own the boards, and the boards must never change once stored. If you find yourself reaching for deepcopy here, that is a sign something is still being mutated — with the code above, the boards are never modified after they are created, so a plain slice is enough.

Final cleanup

The finished game holds its state in three places, each as low as it can be while still seeing what it needs:

  • game owns history and current_move, because it renders both the board and the move list.
  • board owns nothing. It receives x_is_next, squares, and on_play.
  • square owns nothing. It receives value and on_click.

That is the pattern this tutorial was built around. State belongs to the component that needs it and to every component above it — no lower, because then a sibling cannot see it, and no higher, because then unrelated renders redo the work. When two components need the same data, lift it to their nearest common ancestor.

Wrapping up

  • A component is a @component function returning one thing; several children go inside html.fragment(...) or a list.
  • Props are ordinary Python parameters, and they are read-only.
  • use_state gives a component memory; calling the setter re-renders it.
  • Handlers take exactly one argument, the event. Extra context is bound on the Python side, usually with a factory function.
  • Never mutate a list you have put in state — copy it, or the setter silently does nothing.
  • Derive what you can during render instead of storing it: winner, x_is_next, current_squares.
  • Lift state to the lowest component that can see everything that needs it.
  • Give list items a key — in the attribute dict for elements, as a keyword for components.
  • Time travel is free once your data is immutable, and impossible when it is not.
Authors: Archmonger