widgetti / solara

A Pure Python, React-style Framework for Scaling Your Jupyter and Web Apps
https://solara.dev
MIT License
1.9k stars 139 forks source link

(Re)rendering question #759

Open JovanVeljanoski opened 2 months ago

JovanVeljanoski commented 2 months ago

Is there a general (or a specific) guideline on when re-rendering of components takes place? My understanding is that, it is close to "whenever a reactive variable/object is modified, it triggers a (re)render".

But things get a bit murky (for me) when an app gets a bit more complicated, and there are many interlinked components living in different files, "state" files (reactive objects) etc..

My appologies, I tried to make a pycafe example to illustrate this, but it ended up being too complex.. so I will try to add the relevant sections of my code to illustrate the problem / confusion.

in a file called "state.py" i have define a "state" to be used across different pages of the application

@dataclasses.dataclass(frozen=False)
class AppState:
    data: solara.Reactive[Data | None] = solara.reactive(None)
    session_state: solara.Reactive[SessionState] = solara.reactive(SessionState())
    settings: solara.Reactive[Settings] = solara.reactive(Settings())

app_state = AppState()

Then I have a primary, large ish component that goes like this (the main parts only)

# Various obvious imports

@solara.lab.task
def initialize_session(num_questions: int, types: list):
    # Load data (long running process)
    # Other pre-processing and config
    # Result is a dataclass object
    return SessionState(
        question_pool=question_pool,
        current_question=current_question,
        num_questions=num_questions,
        is_session_active=True
    )

@solara.lab.task
def check_answer(user_answer: str, question: QuestionAnswer):
    # Some analysis that could be a long running process
    # Result is a dataclass object
    return SessionState(
        question_pool=session_state.question_pool,
        current_question=session_state.current_question,
        num_questions=session_state.num_questions,
        is_session_active=session_state.is_session_active,
        question_log=session_state.question_log,
        review=session_state.review,
        count_mistakes=session_state.count_mistakes,
        stats_attempted=session_state.stats_attempted,
        stats_correct=session_state.stats_correct
    )

Example()

    types = solara.use_reactive([])
    user_input = solara.use_reactive('')
    current_question = solara.use_reactive(app_state.session_state.value.current_question)
     # Some other reactive and non reactive variables defined.

    def reset():
        new_session_state = SessionState()
        app_state.session_state.set(new_session_state)

    def next():
        pass

    with solara.Div():

        with solara.Card(style={'width': '50%'}):
            solara.SelectMultiple(label='Select category', all_values=app_state.data.value.df['type'].unique().tolist(), values=types, disabled=app_state.session_state.value.is_session_active)

        if app_state.session_state.value.is_session_active is False:
            StartSessionComponent(callback=initialize_session, num_questions=num_questions, types=types.value)
        else:
            QuestionComponent(question=current_question.value, user_input=user_input.value, check_func=check_answer, next_func=next)

Finally i define the components in "components.py"

solara.component
def StartSessionComponent(callback: callable, **kwargs):
    with solara.Card(style={'width': '50%'}):
        solara.Markdown(f"#### Select a category type and press 'Start' to begin.")
        if callback.pending:
            solara.Text('Loading...')
            solara.Button('Start', on_click=lambda: callback(**kwargs), disabled=True)
        elif callback.finished:
            solara.Text('Finished')
            app_state.session_state.set(callback.value)
        else:
            solara.Text('We can go now')
            solara.Button('Start', on_click=lambda: callback(**kwargs), disabled=False)

@solara.component
def QuestionComponent(question: QuestionAnswer, user_input: str, hotkey_active: bool, check_func: callable, next_func: callable):
    # reactive values
    user_input = solara.use_reactive(user_input)
    refocus_trigger = solara.use_reactive(0)
    # other (non) reactive values used elsewhere

    with solara.Card(style={'width': '50%'}):
        solara.Markdown('Test title')

        with FocusOnTrigger(enabled=app_state.session_state.value.is_session_active, target_query='input', refocus_trigger=refocus_trigger.value):
            solara.v.TextField(
                label='Your translation',
                v_model=user_input.value,
                on_v_model=user_input.set,
                disabled=not app_state.session_state.value.is_session_active or question.is_checked,
                continuous_update=True,
                autofocus=True,
            )

        if (question.is_checked is False) and (app_state.session_state.value.is_session_active is True):
            with solara.HBox():
                if check_func.pending:
                    solara.Text('Checking...')
                elif check_func.finished:
                    app_state.session_state.set(check_func.value)
                    print(f'app_state.session_state.current_question: {app_state.session_state.value.current_question}')

                else:
                    solara.Button('Check', on_click=lambda: check_func(user_input.value, question), disabled=question.is_checked)
        else:
            # Do other logic

So basically this is happening.. When run the Example() will show a Start button (from the StartSessionComponent component). Clicking the Start button makes everything behave normally. the app_state is being updated, and the Example() is rerendered showing what comes next (according to the conditions).

When I click the Check button (from the QuestionComponent), the check_answer task runs successfully, and the app_state is correctly updated (I can see this from the print statement). However the Example() is not rerendered.

From what I can see, the approach is basically identical between the usage of the tasks (initialize_session and check_answer) and in both cases the app_state object gets updated correctly. However the former case consistently re-renders the UI, while the later never does.

Aside: I know that that in both cases app_state is correctly updated. When I develop, i have hot-reload on, so if I make a trivial change, the app will "soft-refresh" and it will go to the next expected state, as I would expect it to do when app_state is updated.

I understand this is a .. long convoluted example and not something you can run to figure out what is wrong. Nor do I expect it to be a bug, but more a design/structuring/flow problem. Any advice would be helpful. Thanks!

maartenbreddels commented 2 months ago

Before I try to dig in, I wonder if a bug I recently discovered might also play in here:

In this task example

I just increment in a task. But it breaks when I hit save again, and trigger the hot reload. I first wanted to make you aware of that bug. If this problem you describe still exist, let me know, and i'll dig deeper.

JovanVeljanoski commented 2 months ago

I figured it out - so no need to do any deep dives.

Let me explain the solution: maybe others will make find it useful and maybe the docs can be updated to better explain this concept..

Maybe this is obvious for others, it was not for me - in fact I thought I was doing the right thing but I fell into a trap.

I define a global state for my application like this

@dataclasses.dataclass(frozen=False)
class AppState:
    data: solara.Reactive[Data | None] = solara.reactive(None)
    session_state: solara.Reactive[SessionState] = solara.reactive(SessionState())
    settings: solara.Reactive[Settings] = solara.reactive(Settings())

The AppState dataclass has a session_state attribute that is reactive variable, the value of which should be another dataclass SessionState.

Now, we know that to trigger a re-render, we can not modify reactive variables in place, but we need to pass/set a new instance or a new copy of an instance.

So in the function that resulted with the parent component not rendering i thought i was passing a new instance of SessionState to my AppState, BUT that new SessionState had attributes that were references to the attributes of the old SessionState.

In code instead of words one should NOT do this:

session_state = app_state.session_state.value
session_state.some_list_attribute.append(stuff_to_append)
new_session_state = SessionState(some_list_attribute = session_state.some_list_attribute) # and other attributes etc..
app_state.session_state.set(new_session_state)
# Does not work because `new_session_state` is a new object, BUT `some_list_attribute` is the same list from `session_state`, and this will not trigger a re-render even thought the `app_state` will be correctly updated.

Instead this works

session_state = list(app_state.session_state.value)   # This creates a copy of the list, otherwise one can use copy.deepcopy
session_state.some_list_attribute.append(stuff_to_append)
new_session_state = SessionState(some_list_attribute = session_state.some_list_attribute) # and other attributes etc..
app_state.session_state.set(new_session_state)

This is a case of non-primitive data types. For primitives, python makes a copy so it is not a problem.

My error was that I thought I only need to make sure that the SessionState() dataclass is a new object, but what you need to make sure, is that the data you pass to it is also a new, and not reference to objects in the previous "state" (value of a reactive variable).

Hope this explanation makes sense.

P.S.: An example in the docs of this case might not be a bad idea (just in case).

(This can be closed).