Picnic logo

Beyond Static Pages: bringing UI to life

Written by Florencia BoschApr 3, 2025 09:4816 min read
1*9H catnPhZT2agx5IYW02w

In the previous blog posts, we’ve explored how Picnic’s Page Platform has revolutionized the way we build our mobile grocery shopping experience. As a mobile-only online grocery retailer, our success depends on delivering an exceptional app experience to our customers.

So far, the platform has enabled seamless access to data, allowed the creation of complex UI components on the server, and empowered business and analytics teams to build pages independently, without requiring developer intervention. What started as a powerful tool for rendering content demonstrated even greater potential, inspiring us to push its capabilities further and extend it to support richer, real-time user interactions.

Modern mobile applications are much more than a collection of static pages. Users expect fluid, interactive experiences that respond instantly to their actions. While our platform excelled at content rendering, it lacked the flexibility to support real-time interactions beyond basic navigation. Even seemingly simple behaviors — such as changing a button’s background color on tap — were impossible to implement due to the platform’s static nature. Additionally, dynamic content updates, such as refreshing sections of a page or loading more results when scrolling, were not supported.

A clear example of these limitations can be seen on one of the most important pages in our app: the search screen. This page allows users to refine search results using filters and ideally, selecting a filter should instantly update the displayed products, hiding any that don’t match. However, implementing this kind of real-time filtering within our existing platform was a challenge. Similarly, loading additional results when scrolling or switching to different content types — such as recipes instead of products — was not possible within the system’s constraints.

Bridging the gap between static and dynamic UI

As detailed in our Server-Driven UI in Picnic’s Page Platform blog post, our platform was built to be highly flexible. Instead of relying on high-level components like “recipe-section,” it operates with raw rendering instructions for basic elements such as text, rectangles, and images. This low-level approach provided a solid foundation, enabling us to construct any UI from simple building blocks. However, while it offered powerful customization, it also introduced challenges in handling real-time UI updates. Given the platform’s existing capabilities, enabling dynamic UI updates required us to choose between two possible solutions:

  1. Creating specialized components with built-in interactive behavior, tightly coupling UI logic with the app.
  2. Fetching a new page containing the updated UI state, requiring unnecessary round trips to the server.

Both approaches came with significant trade-offs. Embedding interactive behavior directly into the client side components compromised reusability and maintainability, as any changes would require app updates. Meanwhile, server-driven UI updates introduced latency and disrupted the fluid user experience we aimed to provide.

Take the search filters example: If every filter selection required fetching a newly built page from the server, users would experience noticeable delays in filtering results — an unnecessary hit to responsiveness and user experience.

Search filtering by fetching a new page

We needed a solution that allowed seamless, real-time interactions while keeping the platform flexible, scalable, and decoupled from business logic. Our challenge was to bridge the gap between server-driven UI and the dynamic nature of modern mobile applications — without sacrificing either efficiency or user experience.

Introducing stateful components

To stay true to our goal of defining as much logic as possible on the server, it became clear that we needed a way to define and manage UI state changes on the server and execute them on the client based on the user interactions. The key challenge was enabling components to react to user interactions without requiring a full page reload or pre-built specialized components.

To address this, we extended our DSL for defining layouts to also express state, introducing the concept of an internal page state — a mechanism that allows UI components to track and respond to changes dynamically. Similar to how React updates the UI when state changes, our approach enables components to hook into this internal state, ensuring seamless updates without sacrificing the flexibility and reusability of the platform.

We needed a flexible approach to define and manage state within our UI component hierarchy. Rather than limiting state to the page level, we introduced a system where state boundaries can wrap any component of the component tree. Within these boundaries, UI components can update values and react to state changes dynamically. This encapsulation creates self-contained, reactive units. To establish how state is defined, accessed, and modified within these boundaries, we introduced two key concepts.

State Boundary

A State Boundary is a component responsible for defining a uniquely identifiable state within a page. It specifies which properties should be stored and restricts read/write access to its child components.

Expression

An Expression is a mechanism for dynamically accessing and evaluating state properties within a page. Any UI component property can be defined as an Expression, which is evaluated before rendering.

Expressions can reference state values, allowing components to update them and to vice versa react to their updates. This makes it possible to create pages with reactive, state-driven UI updates using our server-driven UI framework.

To fully unlock the power of Expressions, we needed a way to write them in a developer-friendly, testable, and efficient manner — one that could be executed easily on the client side.

Since our pages are already written in TypeScript (as mentioned in a previous blog post), it made perfect sense to define expressions using the same language, rather than introducing a custom DSL. This approach provided several advantages:

  • Consistency — Developers could stay within the same TypeScript-based stack across the entire system.
  • Flexibility — Complex logic could be written and validated using standard unit testing tools.
  • Seamless Client Execution — Our client is implemented in React Native, which also uses TypeScript, making it straightforward to execute expressions natively without introducing platform-specific logic.

Implementing user interactions in pages

The concepts discussed so far — State Boundaries, Expressions, and state-driven UI updates — come together in a practical way when implementing search filters in our app. This example illustrates how these mechanisms enable reactive user interactions while maintaining the flexibility of a server-driven UI:

  • A StateBoundary component is used to define the `selectedFilter` state, ensuring that the filter selection is stored on the client and accessible to relevant child components.
  • UI properties that need to reflect this state — such as the filter component’s background color — are declared as Expressions. This allows the UI to react dynamically, updating the appearance of the selected filter without requiring a full page reload. Similarly, Expressions can be used to control visibility within the page. The article component, for instance, defines an `isHidden` property as an Expression, which evaluates whether an item should be displayed based on the selected filter. As a result, only products that match the active filter remain visible, ensuring a smooth and responsive user experience.
  • Finally, user interactions drive state updates through Expressions embedded in callbacks. When a user selects a filter, an `onPress` Expression updates the `selectedFilter` state, which in turn triggers all dependent components to reflect the change instantly.

A potential version of the search page supporting this behavior could look like the following:


id="SearchFiltersState"
state={{ selectedFilter: undefined }}>
"filters">
id="filter-1"
backgroundColor={{
type: "EXPRESSION";
expression: "SearchFiltersState.selectedFilter === 'filter-1' ? 'green': 'white'"
}}
onPress={{
type: "EXPRESSION";
expression: "() => { SearchFiltersState.selectedFilter = 'filter-1' }"
}} />

"articles">
id="article-1"
isHidden={{
type: "EXPRESSION";
expression: "SearchFiltersState.selectedFilter !== 'filter-1'"
}} />


Resolving the user interactions on the client

Once the StateBoundary and Expression components reach the client, they need to be processed efficiently to ensure smooth, real-time interactions.

As soon as page data is fetched, all StateBoundaries are removed from the UI structure, and their data is incorporated into the internal page state. The scope of each boundary is preserved, preventing conflicts when multiple components define the same attribute. This consolidated state acts as a central source of truth, allowing UI components to update dynamically.

To evaluate Expressions, the UI component tree is traversed, checking for properties that contain Expressions. When an Expression is detected, it is executed within a JavaScript sandbox, ensuring a controlled and secure evaluation process.

The internal page state is an observable object, meaning that whenever a UI component reads a property value from this state, it automatically subscribes to future updates of that property. If an Expression modifies the state, all dependent components are notified and re-rendered, re-evaluating their Expressions with the latest values.

During this implementation, two key aspects needed to be considered:

  • Performance Optimization — Ensuring that only components affected by state changes are re-rendered, avoiding unnecessary UI updates and improving responsiveness.
  • Security Considerations — Safely executing JavaScript expressions on the client while preventing security vulnerabilities, ensuring sandboxed evaluation.

Revisiting our search filtering example, this means that when a user selects a filter, the `onPress` Expression within the filter component updates the selected filter state. As a result:

  • The filter component re-renders, updating the background color of the selected filter.
  • The article components re-evaluate their `isHidden` Expressions, ensuring only the relevant products remain visible.

This approach ensures a smooth, real-time user experience while keeping UI logic server-driven and highly flexible.

Search filtering with client side state

Fetching new data on demand

The introduction of StateBoundaries and Expressions enabled client-side state updates, allowing UI components to respond to user interactions without requiring a full page reload. However, these updates primarily rely on a combination of existing data and local state, which is not always sufficient. In some cases, new data from the server is necessary to ensure the UI remains accurate and up to date.

Given the platform’s existing capabilities, the only way to retrieve fresh data was to refresh the entire page. While workable, this approach was inefficient — as pages are often aggregated from multiple sources, a small update of only one section would trigger a recalculation of the full page. This resulted in unnecessary processing, longer load times, and larger payloads sent over the network.

In our search experience, this challenge is clearly present in various ways:

  • Lazy loading hidden content — Some sections, such as a list of recipes related to a searched article, remain hidden behind a filter. Ideally, this content should only be fetched when the user selects the corresponding filter or be preloaded in the background when relevant.
  • Dynamic search bar updates — As users type in the search bar, the displayed results should update in real time, fetching relevant suggestions without triggering a full-page reload.
  • On-demand content loading — For broad search terms that return a large dataset, results should be loaded in batches, retrieving more content only when the user scrolls to the bottom of the list.

Introducing partial page reload

To overcome the inefficiencies of full-page reloads, we needed a way to refresh only specific sections of a page while keeping the rest intact. To achieve this, we introduced the Suspense component, inspired by React’s Suspense API.

Similar to StateBoundary, Suspense is not a visible UI element, but rather a structural component that defines how and when parts of the page should be refreshed. When placed within a page, it acts as a placeholder for dynamic content, handling data fetching and determining what should be displayed while waiting for new information.

When a section of the page requires an update, Suspense temporarily replaces it with a loading indicator or continues displaying the previous content until the fresh data is available. Once the updated content is fetched, Suspense seamlessly swaps out the placeholder, ensuring a smooth and responsive user experience. To support different use cases, Suspense is fully configurable, allowing developers to define the best refresh strategy for each scenario.

Partial reloads are triggered through an action, which programmatically defines when and how a section of the page should be refreshed. This action includes three key parameters:

  • Page ID — Specifies which page fragment should be fetched.
  • Suspense ID — Identifies the target section that will be replaced.
  • Loading Component — Defines what should be displayed while waiting for the new content (e.g., a loading spinner or placeholder content).

For example, in the search page, the list of recipes remains hidden until the user selects the corresponding filter. Initially, the main page includes a Suspense component as a placeholder for this content.

"search" >
"filters">
id="filter-recipe"
onPress={{
actionType: ActionType.Reload;
componentId: 'recipe-section'
}} />

"results">
id="recipe-section"
pageConfiguration={{
id: 'search-recipe-results'
}} />

When the user taps the filter, an action is triggered, fetching only the necessary data for that section. Once the response is received, the Suspense component seamlessly swaps out the placeholder with the newly loaded recipe results.

"search-recipe-results" >
"recipes">
"recipe-1" />
"recipe-2" />

By leveraging partial page reloads, we ensure that only the relevant data is fetched, reducing unnecessary requests and improving both performance and responsiveness. This approach keeps interactions fluid and dynamic, allowing users to experience seamless content updates without the delays of a full-page reload.

Lazy loading of recipe search results

Conclusion

As our Page Platform evolved, it became clear that static content alone wasn’t enough to deliver the dynamic, real-time interactions users expect. By introducing StateBoundaries and Expressions, we enabled state-driven UI updates, allowing components to react to user interactions without requiring a full page reload. However, for cases where new data was needed, we extended our approach further with partial page reloads using the Suspense component.

Together, these enhancements strike a balance between flexibility and performance. Developers can now build richer, more interactive experiences while maintaining the benefits of a server-driven UI — efficient updates, a centralized logic model, and streamlined client-side implementation.

With these improvements, we now have a way to create dynamic client-side experiences and fetch new data on demand. But there’s still one missing piece: enabling users to provide input, submitting it to the server, and making it available in the next rendering cycle. Stay tuned for our next blog post, where we’ll explore how we took the next step in evolving our platform to power fully interactive experiences.


Beyond Static Pages: bringing UI to life was originally published in Picnic Engineering on Medium, where people are continuing the conversation by highlighting and responding to this story.

picnic blog job banner image

Want to join Florencia Bosch in finding solutions to interesting problems?