DEV Community

Cover image for Managing application state in Puck
Fede Bonel Tozzi for Puck

Posted on • Originally published at measured.co

Managing application state in Puck

Puck is the open-source visual editor for React, empowering the next generation of page builders and no-code products. Give us a star on GitHub! ⭐️


Puck has been rapidly growing, and it’s been awesome to watch! 🚀 Developers from all backgrounds are pushing the boundaries of what this open-source visual editor can do. But as more people dive into Puck, one question keeps coming up in our Discord community:

“How can I pass data or share state between components in Puck?”

In other words: how do you make one component react to changes in another? For example, you might create a DropZone component with a search input, so that any lists dropped inside it can read its value:

Intro example

At first, Puck’s built-in magic might make you think it handles state in a unique way. But here’s the thing: Puck is just React—and so are the components you pass into it. That means that you can rely on any state library or tool you would normally use to manage and share data between components. For this article, I’ll keep it simple and teach you how to solve this problem by using Context.

Before we get started: I’ll assume you’ve got a basic understanding of Puck and how it works. If you’re new here, that’s totally fine—you’re welcome to follow along! But I’d recommend checking out the getting started guide first to get familiar with the basics

Project Setup

To make things easy, I’ve prepared a basic React project on GitHub with Puck pre-installed and ready to go. Clone and install it by running the following commands in your terminal:

git clone https://github.com/FedericoBonel/basic-puck-app
cd ./basic-puck-app
npm install
Enter fullscreen mode Exit fullscreen mode

Already working on an existing project? No problem at all! You can simply install Puck as a dependency with NPM:

npm i @measured/puck --save
Enter fullscreen mode Exit fullscreen mode

And if you’re using frameworks like Next.js or Remix, Puck offers official recipes to make the setup process seamless.

For this tutorial, I’ll assume you’ve cloned the GitHub repo to keep things straightforward. That said, the concepts and steps will apply to any setup—just update the file names as needed to fit your project’s structure.

Configuring Puck

With your project ready to go, the next step is to configure Puck. Open the src/App.jsx file and swap its contents with the code below. This will set up Puck with a basic config for dragging and dropping two components:

  • A Dashboard component that greets users and contains a DropZone for nesting other components
  • An ArticleList component that displays a list of articles inside the Dashboard
// App.jsx
import { Puck, DropZone } from "@measured/puck";
import "@measured/puck/puck.css";

// The configs for your draggable components
// Ideally you would pull these out into their own files
const dashboardConfig = {
  render: () => {
    return (
      <div
        style={{
          minWidth: "fit-content",
          maxWidth: "50%",
          margin: "auto",
        }}
      >
        <h3>Welcome User!</h3>
        <DropZone zone="list" />
        <a href="/signup">Sign up now</a>
        <p style={{ fontSize: "14px" }}>
          Already have an account? <a href="/signin">Sign in here</a>
        </p>
      </div>
    );
  },
};

const articleListConfig = {
  render: () => {
    // These articles could come from an external data source
    const articles = ["Puck", "React State", "NextJS"];

    return (
      <ul>
        {articles.map((article, i) => <li key={i}>{article}</li>)}
      </ul>
    );
  },
};

// The Puck configuration object
const config = {
  components: {
    Dashboard: dashboardConfig,
    ArticleList: articleListConfig,
  },
};

// Your editor component
const App = () => {
  return <Puck config={config} data={{}} />;
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Once you’ve updated the file, start the application in development mode, and navigate to http://localhost:5173 to verify everything is working as expected:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Initial Setup

Great! Your basic setup is now complete. Next, let’s dive into adding shared state to your editor.

Adding Context

React Context is the perfect solution for our problem because it offers a simple way to share and manage data across all your components—both inside and outside the editor. It creates a "global state" that you can access whenever needed, making it ideal for scenarios where you need to pull in data from outside Puck—like the selected theme or the logged-in user—or share data between Puck components.

In this guide, I’ll walk you through two common use cases for React Context within Puck:

  1. Accessing data stored outside Puck: We’ll begin by setting up a context containing a logged-in user’s data outside of the <Puck /> component, and then access it from within Puck components.
  2. Passing data to a nested component: Next, we’ll set up a search query context within the Dashboard. This will allow us to capture a user’s search query, store it in the context, and pass it down to the ArticleList component. The goal is to filter the list of articles based on the user’s query, demonstrating how to pass data between a parent and child Puck component.

Step 1: Define the Context outside Puck

Setting up context in Puck follows the same pattern as any React app. You create a Context provider to define and manage your shared state, wrap it around a parent component, and access or update the state wherever it's required in your app.

Start by creating a new Context for the user data. This Context will include both the user object and a function to update the user state.

// context/userContext.jsx
import { createContext, useContext, useState } from "react";

// Create the Context
const UserContext = createContext(null);

// Custom hook to use the User Context
const useUserContext = () => {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error("useUserContext must be used within a UserProvider");
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Context Provider outside Puck

Next, create a UserProvider component that will wrap your Puck editor. This provider will manage the user state and make it available to all children.

For the sake of brevity, I’m using a dummy user and the setter function returned by useState.

// context/userContext.jsx

//... existing setup

//eslint-disable-next-line react/prop-types
const UserProvider = ({ children }) => {
  // Start the context state with a dummy user
  const [user, setUser] = useState({
    name: "John Doe",
  });

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
};

export { UserProvider, useUserContext };
Enter fullscreen mode Exit fullscreen mode

Step 3: Integrate the Provider with Puck

To integrate the provider with your Puck editor, simply wrap the editor with the UserProvider. You can put the UserProvider anywhere above the editor in your component tree (like in your index file), and it'll work just fine. Once you've done that, all your editor components will have access to the context!

// App.jsx
import { UserProvider, useUserContext } from "./context/UserContext";

//... existing setup

const App = () => {
  return (
    <UserProvider>
      <Puck config={config} data={{}} />
    </UserProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Consume the Context in the Puck Components

Now you can access the UserContext in any of your Puck components. Following our use case example, let’s update the Dashboard component so that it displays a “welcome back” message for logged-in users and a “generic welcome” message for guests.

// App.jsx
import { useUserContext } from "./context/UserContext";

//... existing setup

const dashboardConfig = {
  render: () => {
    // Access the user context
    const { user, setUser } = useUserContext();

    return (
      <div
        style={{
          minWidth: "fit-content",
          maxWidth: "50%",
          margin: "auto",
        }}
      >
        {/* Read the user's name */}
        <h3>Welcome back {user?.name ?? ""}</h3>
        <DropZone zone="list" />

        {/* Update the user on logout */}
        {user && <button onClick={() => setUser(null)}>Sign out 🏃🚪</button>}
      </div>
    );
  },
};

//... the Puck config
Enter fullscreen mode Exit fullscreen mode

If you're using eslint, you may see the linting error ‘React Hook "useUserContext" is called in function "render" that is neither a React function component nor a custom React Hook function.’ This is because ESLint can’t recognise the syntax of the render function as a component.
To fix this you can pull the render function into its own component and pass it (render: WelcomeCard), or ask ESLint to ignore the line (eslint-disable-next-line react-hooks/rules-of-hooks).

Now you can go back to the editor, drag and drop the new Dashboard component, and you'll see that the UserContext is being accessed to display the “welcome back” message for the dummy logged-in user:

Final first use case demo

If you were to publish that data and pass it to the Render component using the same config and UserProvider, the result would look just as it was in the editor, and you could also click on the sign-out button to clear the logged-in user state:

Final first use case demo in render component

With this done, you now have a state originating outside of Puck, seamlessly passed to and updated by Puck components! ✔️

Step 5: Adding a search query

Now let's move to our second use case: Passing data from a Puck component to another.

To make this work, we’ll add a search query input and an initialQuery prop to the Dashboard component. This will allow users to filter the list of articles and give us the option to set an initial query for them to start with.

// App.jsx
import { createContext, useContext, useState, useEffect } from "react";

//.. existing setup

const dashboardConfig = {
  // Set the default props for the initial query field
  defaultProps: {
    initialQuery: "React",
  },

  // Create a field for setting the inital query
  fields: {
    initialQuery: {
      label: "Initial Search Query",
      type: "text",
    },
  },

  // Read the initial query prop
  render: ({ initialQuery }) => {
    // ... existing dashboard setup

    // Create the state for the search query
    const [query, setQuery] = useState(initialQuery);

    // Update the query's value from the editor's field
    useEffect(() => {
      setQuery(initialQuery);
    }, [initialQuery]);

    return (
      <div
        style={{
          minWidth: "fit-content",
          maxWidth: "50%",
          margin: "auto",
        }}
      >
        <h3>Welcome back {user?.name ?? ""}</h3>

        {/* Create the input the user will use to filter the lists */}
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
        />

        <DropZone zone="list" />
        {user && <button onClick={() => setUser(null)}>Sign out 🏃🚪</button>}
      </div>
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 6: Provide context from a Puck component

Next, let’s set up a context in the Dashboard component to pass down the query state. This will ensure that when the user updates the search input, the new query is stored in the context and shared with the ArticleList or any other component dropped inside the Dashboard.

// App.jsx
//... existing setup

// Create the query context that will pass down the query state
const QueryContext = createContext();
const useQueryContext = () => useContext(QueryContext);

const dashboardConfig = {
  render: () => {
    // ... existing dashboard setup

    return (
      <QueryContext.Provider value={{ query, setQuery }}>
        {/* ... existing dashboard component */}
      </QueryContext.Provider>
    );
  },
};

//... the Puck config
Enter fullscreen mode Exit fullscreen mode

Step 7: Consume the Context from Puck components

Now, we’ll read the context in the components that are dropped inside the context provider. In our case, we’ll consume the context in the ArticleList component, which the user has nested inside the Dashboard via the DropZone. This allows the ArticleList to respond to changes in the search query and update accordingly.

// App.jsx
//... existing setup

const articleListConfig = {
  render: () => {
    // Access the query state
    const { query } = useQueryContext();

    // Filter out the articles based on the query
    const articles = ["Puck", "React State", "NextJS"].filter(
      (article) => article.indexOf(query) !== -1
    );

    //... existing return statement
  },
};
Enter fullscreen mode Exit fullscreen mode

If you now head into the editor, drag a Dashboard component onto the canvas, drop an ArticleList inside it, and modify the initialQuery field, you’ll see the list dynamically filter the articles based on the query. 🎯

Final second use case demo

You could even expand this setup by having multiple list components with different content reuse the same query context.

🎉 And that’s it! You now have shared state between nested Puck components. 🎉

Pros & Cons of using React Context

✅ Pros:

  • Provides a robust solution for sharing state across components, both inside and outside Puck
  • Integrates seamlessly with existing React patterns and components
  • Can handle complex logic and state
  • Zero external dependencies as React Context comes with React

❌ Cons:

  • Performance can degrade if you frequently update state at the top of a large component tree, as every subscriber needs to re-render
  • When managing multiple context providers, things can become harder to debug

Taking it further

There are a bunch of ways in which you can improve managing shared state in Puck depending on the complexity of your editor:

  • Optimize context usage - If you notice performance issues or unnecessary re-renders, consider splitting your context into smaller, more focused contexts. This allows components to subscribe only to the parts of the state they need, minimizing re-renders.
  • Incorporate state libraries - If you have multiple shared states and more complex logic, you could move beyond React Context and use your favorite state library. Whether it’s Redux, Zustand, or another library your project is already using, these can simplify managing complex state and improve rendering performance.
  • Leverage server-side state - If your application relies heavily on data fetched from a server, consider using a library like TanStack Query or SWR. These libraries manage caching, re-fetching, and synchronization for you, reducing the need for complex shared client-side state.

Your Turn to Build Smarter with Puck 💡

Taking shared state management in Puck to the next level unlocks a world of possibilities for building dynamic, reactive page builders. I’m excited to see the unique and powerful apps you’ll build using these strategies.

So, if this article has inspired you to build something, you have a question about Puck or you want to contribute, here’s how you can get involved:

  • 🌟 Star Puck on GitHub to show your support and inspire others to explore.
  • 🤝 Join our Discord community to connect, learn, and collaborate.
  • 📢 Follow us on X and Bluesky for sneak peeks, updates, and tips.
  • 📘 Explore the documentation for advanced techniques to supercharge your builds.

The future of Puck—and no-code innovation—is in your hands. Start building today, and let’s redefine what’s possible together! 🙌

Top comments (1)

Collapse
 
chrisvxd profile image
Chris Villa

Great write up! I get asked this a lot.

Interestingly, I think the new metadata API proposal might introduce some more patterns for this: github.com/measuredco/puck/issues/798