Blog
Releases

Upgrading to Puck 0.19

Fede Bonel TozziFede Bonel Tozzi
Jun 5, 2025

Puck 0.19 adds a lot of new features, and while updating won’t cause immediate breaking changes, some features include deprecations or behavioral differences that may affect your setup if you opt-in to them.

In this post, we will guide you through the steps for upgrading to Puck 0.19:

If you encounter any bugs as you upgrade to Puck 0.19, please report them via GitHub.

For a list of all the new features added to Puck 0.19, check out the release blog post.

Installing Puck 0.19

Run the following command to install the latest version of Puck:

npm install --save-exact @measured/puck@^0.19.0

Breaking changes

Puck 0.19 doesn’t introduce any breaking changes, so upgrading from Puck 0.18 won’t require updates to your existing integration.

However, if you choose to adopt the new slots API, there are changes to the data model that could break existing saved data or custom data logic. The next section outlines what those changes are and everything you need to do to handle them safely.

New deprecations

Deprecated: DropZone

In Puck 0.19, we’re replacing DropZones with slots and deprecating the DropZone component.

Using the DropZone component will warn:

⚠️ DropZones have been deprecated in favor of slot fields and will be removed in a future version of Puck. Please see the migration guide: https://www.puckeditor.com/docs/guides/migrations/dropzones-to-slots

To migrate to slots, replace your <DropZone /> instances with slot fields. We recommend using the same name for the slot field as the original zone prop provided to your DropZone instance to assist with data migration:

// Before import { DropZone } from "@measured/puck"; const config = { components: { Flexbox: { render: () => { return <DropZone zone="FlexZone" style={{ display: "flex" }} />; }, }, }, };
// After const config = { Flexbox: { fields: { // Match this name (FlexZone) to your previous zone prop FlexZone: { type: "slot" }, }, render: ({ FlexZone }) => { // Replace the DropZone with the slot return <FlexZone style={{ display: "flex" }} />; }, }, };

Deprecated: puck.renderDropzone

Since slots work in both client and server components, puck.renderDropzone is being replaced by slots and is now deprecated.

Using puck.renderDropzone will warn:

⚠️ DropZones have been deprecated in favor of slot fields and will be removed in a future version of Puck. Please see the migration guide: https://www.puckeditor.com/docs/guides/migrations/dropzones-to-slots

To migrate to slots, replace your puck.renderDropzone calls with slot fields. We recommend using the same name for the slot field as the original zone prop provided to your puck.renderDropzone call to assist with data migration:

// Before const config = { components: { Flexbox: { render: ({ puck }) => { return ( <div> {puck.renderDropzone({ zone: "FlexZone", style: { display: "flex" }, })} </div> ); }, }, }, };
// After const config = { Flexbox: { fields: { // Match this name (FlexZone) to your previous zone prop FlexZone: { type: "slot" }, }, render: ({ FlexZone }) => { // Replace the puck.renderDropzone call with the slot return <FlexZone style={{ display: "flex" }} />; }, }, };

Deprecated: data.zones

When using Slots, nested components are stored recursively as props against the component that defines the slot, instead of as zones in the data payload. If you’re migrating from DropZones to slots, you must update any existing data.

Before:

{ "content": [ { "type": "Flexbox", "props": { "id": "Flexbox-12345" } } ], "zones": { "Flexbox-12345:FlexZone": [ { "type": "HeadingBlock", "props": { "id": "Heading-12345", "title": "This is a nested heading" } } ] } }

After:

{ "content": [ { "type": "Flexbox", "props": { "id": "Flexbox-12345", "FlexZone": [ { "type": "HeadingBlock", "props": { "id": "Heading-12345", "title": "This is a nested heading" } } ] } } ] }

To update your data, use the migrate utility. This function will convert all DropZone data stored under data.zones into recursive slots. It takes two arguments: the legacy data object, and the current config with slot definitions.

For the migration to work, make sure the field name of each slot in your config matches the zone prop that was used in the DropZone it replaces.

import { migrate } from "@measured/puck"; const newConfig = { Flexbox: { fields: { FlexZone: { type: "slot" }, }, render: ({ FlexZone }) => { return <FlexZone style={{ display: "flex" }} />; }, }, }; const legacyData = { root: {}, content: [ { type: "Flexbox", props: { id: "Flexbox-12345", }, }, ], zones: { "Flexbox-12345:FlexZone": [ { type: "HeadingBlock", props: { id: "Heading-12345", title: "Header", }, }, ], }, }; const newData = migrate(legacyData, newConfig); // { // "content": [ // { // "type": "Flexbox", // "props": { // "id": "Flexbox-12345", // "FlexZone": [ // { // "type": "HeadingBlock", // "props": { // "id": "Heading-12345", // "title": "Header" // } // } // ] // } // } // ] // }

Migrating data traversal

If you’re manually traversing or modifying the data payload, you’ll need to account for the data model change when using slots.

To do this, you can use the new walkTree utility. This function recursively walks your entire data payload, and allows for optional updates.

// Before const processContent = (content) => { // Add the "example" prop to all children return content.map((child) => ({ ...child, props: { ...child.props, example: "Hello, world" }, })); }; const processedData = data; // Update `data.content` processedData.content = processContent(data.content); // Update `data.zones` processedData.zones = Object.keys(data.zones).reduce((newZones, zoneKey) => { const nestedComponents = data.zones[zoneKey]; return { ...newZones, [zoneKey]: processContent(nestedComponents), }; }, {});
// After import { walkTree } from "@measured/puck"; const processedData = walkTree(data, config, (nestedComponents) => { // Add the "example" prop to all children return nestedComponents.map((child) => ({ ...child, props: { ...child.props, example: "Hello, world" }, })); });

Notable changes

Minimize re-renders with createUsePuck and useGetPuck

Before 0.19, using usePuck subscribed your component to the entire internal Puck API, causing it to trigger re-renders on every state change. To avoid that, Puck now provides two utilities: createUsePuck and useGetPuck.

createUsePuck

createUsePuck is a hook factory that lets you create a version of usePuck that supports selectors, allowing you to specify which parts of the state or Puck API you want to react to.

We recommend using this approach whenever you want to use a part of the Puck state to render your component:

// Before import { usePuck } from "@measured/puck"; const PreviewModeIndicator = () => { // Re-renders on every appState change const { appState } = usePuck(); return <p>{appState.ui.previewMode}</p>; };
// After import { createUsePuck } from "@measured/puck"; const usePuck = createUsePuck(); const PreviewModeIndicator = () => { // Re-renders only when preview mode changes const previewMode = usePuck((s) => s.appState.ui.previewMode); return <p>{previewMode}</p>; };

useGetPuck

useGetPuck returns a getter function that gives you access to the latest Puck API without subscribing to changes, meaning your component won’t re-render because of it.

We recommend using this approach whenever possible, such as in callbacks:

// Before import { usePuck } from "@measured/puck"; const SaveDataButton = () => { // Re-renders on every appState change const { appState } = usePuck(); const handleClick = () => saveData(appState.data); return <button onClick={handleClick}>Save your progress</button>; };
// After import { useGetPuck } from "@measured/puck"; const SaveDataButton = () => { const getPuck = useGetPuck(); const handleClick = useCallback(() => { // Only get the appState when the button is clicked const { appState } = getPuck(); saveData(appState.data); }, [getPuck]); return <button onClick={handleClick}>Save your progress</button>; };

Getting component data with appState.ui.itemSelector.zone

The zone parameter on the item selector has been updated to support slots.

This parameter is a concatenated string, made up of the parent id (that defines the DropZone or slot) and an identifier for that particular zone (the DropZone zone prop, or name of the slot field).

// Before const config = { Example: { render: () => { return <DropZone zone="myZone" />; }, }, }; console.log(appState.ui.itemSelector.zone); // "Example-1234:myZone"
// After const config = { Example: { fields: { mySlot: { type: "slot", }, }, render: ({ mySlot: MySlot }) => { return <MySlot />; }, }, }; console.log(appState.ui.itemSelector.zone); // "Example-1234:mySlot"

This change only affects you if you were using itemSelector.zone to look up content in data.zones. The new getItemBySelector method provided by the PuckApi will enable you to migrate this behavior:

// Before import { usePuck } from "@measured/puck"; const selector = { zone: "Example-1234:myZone", index: 0 }; const LogItemButton = () => { const { appState } = usePuck(); return ( <button onClick={() => { console.log(appState.data.zones[selector.zone][selector.index]); }} > Announce item </button> ); };
// After import { useGetPuck } from "@measured/puck"; const selector = { zone: "Example-1234:mySlot", index: 0 }; const LogItemButton = () => { // Also migrate to useGetPuck to prevent re-renders const getPuck = useGetPuck(); return ( <button onClick={() => { const { getItemBySelector } = getPuck(); console.log(getItemBySelector(selector)); }} > Announce item </button> ); };

Full changelog

Check out the full changelog, including known issues, on the GitHub release.

Learn more about Puck

If you’re interested in learning more about Puck, check out the demo or read the docs. If you like what you see, please give us a star on GitHub to help others find Puck too!