How can I use React hooks when migrating from Bud.js to new Vite plugin?

I’ve been using Bud.js and it’s been great - thank you for this plugin! It still works well, but now I’m trying to migrate to the new Vite plugin, and I’m running into some issues. Particularly with getting my old blocks to work, especially when they rely on React hooks.

For example, in the following setup, my block throws an error:

import { useEffect } from 'react';

export function useSetBlockIdEffect(clientId: string, setAttributes: Function) {
    useEffect(() => {
        setAttributes({ blockId: clientId });
    }, [clientId]);
}

Error:

Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

I know that if I change import { useEffect } from 'react' to @wordpress/element, it works fine. However, I’m also using import { create } from 'zustand', which worked well with Bud.js but not with the new Vite plugin.

Is there a configuration step I’m missing? Or are React hooks not fully supported by the new Vite plugin setup?

Are you getting the same exact error from your OP or is it something different? Can you show your code/errors for that?

What does your Vite config and package.json look like?

Here is my vite config.

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from "vite"
import laravel from "laravel-vite-plugin"
import { wordpressPlugin } from '@roots/vite-plugin'

export default defineConfig({
    base: "/wp-content/plugins/blocks/public/build",
    plugins: [
        laravel({
            input: [
                "src/blocks.ts",
            ],
            refresh: true,
        }),
        wordpressPlugin()
    ],
    publicDir: "public/build",
    build: {
        sourcemap: true,
        assetsInlineLimit: 0,
        rollupOptions: {
            external: [/^@wordpress\//],
            onwarn(warning, warn) {
                if (warning.message.includes('.svg')) return
                warn(warning)
            },
        },
    },
    server: {
        host: true,
        https: {
            key: "./ssl/localhost.key",
            cert: "./ssl/localhost.crt",
        },
        hmr: {
            host: "localhost",
        },
    },
    resolve: {
        alias: {
            "@src": fileURLToPath(new URL('./src', import.meta.url)),
            '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)),
            '@store': fileURLToPath(new URL('./src/store', import.meta.url)),
            '@t': fileURLToPath(new URL('./src/types', import.meta.url)),
        },
    },
})

I use similar action like you use in sage theme to load asset from for dev server, I can compile basic “Hello World” with my setup using hooks from @wordpress/element and it does work without errors.

add_filter('admin_head', function () {

    if (! get_current_screen()?->is_block_editor()) {
        return;
    }
    $hotFilePath = __DIR__ . '/public/hot';

    Vite::useBuildDirectory('../../../plugins/blocks/public/build');

    if (file_exists($hotFilePath)) {
        Vite::useHotFile($hotFilePath);
    }

    $dependencies = json_decode(Vite::content('editor.deps.json'));
    foreach ($dependencies as $dependency) {
        if (! wp_script_is($dependency, 'enqueued')) {
            wp_enqueue_script($dependency);
        }
    }

    $html = Vite::withEntryPoints([
        'src/blocks.ts',
    ])->toHtml();
    echo $html;
});

here is what I use in my edit function which causes an error.

import { create } from 'zustand'

interface PanelBodies {
    options: boolean
    layout: boolean
    dimensions: boolean
    borderRadius: boolean
    backgroundMedia: boolean
    margin: boolean
    padding: boolean
}

interface GlobalState {
    breakpoint: Breakpoint
    viewportLocked: boolean
    panelBodies: PanelBodies
    setDeviceBreakpoint: (breakpoint: Breakpoint) => void
    setViewportLocked: (locked: boolean) => void
    setPanelBodies: (panelBody: keyof PanelBodies, value: boolean) => void

}

export const useGlobalStore = create<GlobalState>((set) => ({
    panelBodies: {
        options: true,
        layout: false,
        dimensions: false,
        borderRadius: false,
        backgroundMedia: false,
        margin: false,
        padding: false,
    },
    setPanelBodies: (panelBody, value) => {
        set((state) => ({
            panelBodies: {
                ...state.panelBodies,
                [panelBody]: value,
            },
        }))
    },
    breakpoint: 'sm',
    viewportLocked: true,
    setDeviceBreakpoint: (breakpoint: Breakpoint) =>
        set(() => ({ breakpoint })),
    setViewportLocked: (locked: boolean) =>
        set(() => ({ viewportLocked: locked })),
}))

export default useGlobalStore
    import useGlobalStore from "@src/store"
    const {
         panelBodies,
         setPanelBodies,
        breakpoint: currentBreakpoint,
    } = useGlobalStore()
load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,utils,wp-dom-ready,wp-hooks&ver=6.8.1:5 JQMIGRATE: Migrate is installed, version 3.4.1
@wordpress_icons.js?v=f42ce3a1:21613 Download the React DevTools for a better development experience: https://reactjs.org/link/react-devtools
effects.ts:4 Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.
printWarning @ chunk-CANBAPAS.js?v=f42ce3a1:136
error @ chunk-CANBAPAS.js?v=f42ce3a1:120
resolveDispatcher @ chunk-CANBAPAS.js?v=f42ce3a1:1045
useEffect @ chunk-CANBAPAS.js?v=f42ce3a1:1077
useSetBlockIdEffect @ effects.ts:4
edit @ ContainerEdit.tsx:83
ht @ react-dom.min.js?ver=18.3.1.1:10
Qs @ react-dom.min.js?ver=18.3.1.1:10
wl @ react-dom.min.js?ver=18.3.1.1:10
bl @ react-dom.min.js?ver=18.3.1.1:10
yl @ react-dom.min.js?ver=18.3.1.1:10
fl @ react-dom.min.js?ver=18.3.1.1:10
Nn @ react-dom.min.js?ver=18.3.1.1:10
(anonymous) @ react-dom.min.js?ver=18.3.1.1:10
effects.ts:4 Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.
printWarning @ chunk-CANBAPAS.js?v=f42ce3a1:136
error @ chunk-CANBAPAS.js?v=f42ce3a1:120
resolveDispatcher @ chunk-CANBAPAS.js?v=f42ce3a1:1045
useEffect @ chunk-CANBAPAS.js?v=f42ce3a1:1077
useSetBlockIdEffect @ effects.ts:4
edit @ ContainerEdit.tsx:83
ht @ react-dom.min.js?ver=18.3.1.1:10
Qs @ react-dom.min.js?ver=18.3.1.1:10
wl @ react-dom.min.js?ver=18.3.1.1:10
bl @ react-dom.min.js?ver=18.3.1.1:10
yl @ react-dom.min.js?ver=18.3.1.1:10
il @ react-dom.min.js?ver=18.3.1.1:10
fl @ react-dom.min.js?ver=18.3.1.1:10
Nn @ react-dom.min.js?ver=18.3.1.1:10
(anonymous) @ react-dom.min.js?ver=18.3.1.1:10
react-dom.min.js?ver=18.3.1.1:10 TypeError: Cannot read properties of null (reading 'useEffect')
    at useEffect (chunk-CANBAPAS.js?v=f42ce3a1:1078:29)
    at useSetBlockIdEffect (effects.ts:4:5)
    at edit (ContainerEdit.tsx:83:5)
    at ht (react-dom.min.js?ver=18.3.1.1:10:45677)
    at Qs (react-dom.min.js?ver=18.3.1.1:10:120133)
    at wl (react-dom.min.js?ver=18.3.1.1:10:88341)
    at bl (react-dom.min.js?ver=18.3.1.1:10:88269)
    at yl (react-dom.min.js?ver=18.3.1.1:10:88132)
    at il (react-dom.min.js?ver=18.3.1.1:10:84984)
    at fl (react-dom.min.js?ver=18.3.1.1:10:85364)
ar @ react-dom.min.js?ver=18.3.1.1:10
a.componentDidCatch.t.callback @ react-dom.min.js?ver=18.3.1.1:10
at @ react-dom.min.js?ver=18.3.1.1:10
Jr @ react-dom.min.js?ver=18.3.1.1:10
Zr @ react-dom.min.js?ver=18.3.1.1:10
Gr @ react-dom.min.js?ver=18.3.1.1:10
(anonymous) @ react-dom.min.js?ver=18.3.1.1:10
xl @ react-dom.min.js?ver=18.3.1.1:10
fl @ react-dom.min.js?ver=18.3.1.1:10
Nn @ react-dom.min.js?ver=18.3.1.1:10
(anonymous) @ react-dom.min.js?ver=18.3.1.1:10

package.json

{
    "name": "modules",
    "version": "1.0.0",
    "private": true,
    "type": "module",
    "scripts": {
        "prod": "bud clean && bud build",
        "dev": "bud clean && bud dev",
        "vite": "vite",
        "vite-build": "vite build",
        "watchTsxFiles": "bun run task.ts",
        "clean": "bud clean"
    },
    "dependencies": {
        "fast-average-color": "^9.5.0",
        "react-tabs": "^6.1.0",
        "zustand": "^5.0.3"
    },
    "devDependencies": {
        "@rollup/plugin-inject": "^5.0.5",
        "@roots/bud": "^6.24.0",
        "@roots/bud-api": "^6.24.0",
        "@roots/bud-build": "^6.24.0",
        "@roots/bud-cache": "^6.24.0",
        "@roots/bud-client": "^6.24.0",
        "@roots/bud-compiler": "^6.24.0",
        "@roots/bud-dashboard": "^6.24.0",
        "@roots/bud-entrypoints": "^6.24.0",
        "@roots/bud-extensions": "^6.24.0",
        "@roots/bud-framework": "^6.24.0",
        "@roots/bud-hooks": "^6.24.0",
        "@roots/bud-minify": "^6.24.0",
        "@roots/bud-postcss": "^6.24.0",
        "@roots/bud-preset-recommend": "^6.24.0",
        "@roots/bud-preset-wordpress": "^6.24.0",
        "@roots/bud-react": "^6.24.0",
        "@roots/bud-sass": "^6.24.0",
        "@roots/bud-server": "^6.24.0",
        "@roots/bud-support": "^6.24.0",
        "@roots/bud-swc": "^6.24.0",
        "@roots/bud-tailwindcss": "^6.24.0",
        "@roots/bud-wordpress-dependencies": "^6.24.0",
        "@roots/bud-wordpress-externals": "^6.24.0",
        "@roots/bud-wordpress-theme-json": "^6.24.0",
        "@roots/vite-plugin": "^1.0.4",
        "@roots/wordpress-hmr": "^6.24.0",
        "@svgr/webpack": "^8.1.0",
        "@types/jquery": "^3.5.32",
        "@types/lodash": "^4.17.16",
        "@types/lodash-es": "^4.17.12",
        "@types/node": "^22.14.1",
        "@types/react": "^18.3.20",
        "@types/react-dom": "^18.3.6",
        "@types/wordpress__block-editor": "^11.5.16",
        "@types/wordpress__blocks": "^12.5.17",
        "@types/wordpress__edit-post": "^8.4.2",
        "@wordpress/api-fetch": "^7.22.0",
        "@wordpress/block-editor": "^14.17.0",
        "@wordpress/core-data": "^7.22.0",
        "@wordpress/dom-ready": "^4.22.0",
        "@wordpress/element": "^6.22.0",
        "@wordpress/icons": "^10.22.0",
        "@wordpress/plugins": "^7.22.0",
        "add": "^2.0.6",
        "autoprefixer": "^10.4.21",
        "bun": "^1.2.10",
        "chokidar": "^4.0.3",
        "classnames": "^2.5.1",
        "clsx": "^2.1.1",
        "css-minimizer-webpack-plugin": "^7.0.2",
        "laravel-vite-plugin": "^1.2.0",
        "lodash": "^4.17.21",
        "lodash-es": "^4.17.21",
        "mysql2": "^3.14.0",
        "php-unserialize": "^0.0.1",
        "postcss": "^8.5.3",
        "postcss-loader": "^8.1.1",
        "prettier": "^3.5.3",
        "prettier-plugin-tailwindcss": "^0.6.11",
        "raw-loader": "^4.0.2",
        "react": "^18.3.1",
        "react-dom": "^18.3.1",
        "react-number-format": "^5.4.4",
        "sass": "^1.86.3",
        "sass-loader": "^16.0.5",
        "swc-loader": "^0.2.6",
        "tailwindcss": "^3.4.17",
        "terser-webpack-plugin": "^5.3.14",
        "typescript": "^5.8.3",
        "vite": "^6.3.5",
        "workbox-precaching": "^7.3.0"
    },
    "volta": {
        "node": "22.12.0"
    }
}

Quite a lot going on here, and just wanted to give you a heads up that this isn’t something that we can spend time trying to further look into right now considering this isn’t a Sage based project and there isn’t a minimal reproduction

Ok how about this, this block works.

import { useRef, useEffect } from '@wordpress/element'
import { registerBlockType } from '@wordpress/blocks'

function useSetBlockIdEffect(clientId: string, setAttributes: Function) {
    useEffect(() => {
        setAttributes({ blockId: clientId })
    }, [clientId])
}

// Icons
import { ContainerIcon } from "@src/lib/block-icons"

import {
    useBlockProps,
} from '@wordpress/block-editor'

export const meta = {
    name: "test/block",
    title: "TestBlock",
    icon: ContainerIcon,
    apiVersion: 3,
    supports: {
        color: {
            background: false,
            gradients: true,
            text: true,
        },
        typography: {
            fontSize: true,
            lineHeight: true,
        },
        shadow: true,
    },
    category: "layout",
}

export const edit = ({ clientId, attributes, setAttributes }: any) => {
    const { blockId } = attributes
    console.log({ blockId, clientId, setAttributes })
    useSetBlockIdEffect(clientId, setAttributes)
    const containerRef = useRef<HTMLElement>(null)

    const blockProps = useBlockProps({
        className: `test-id-${ blockId }`,
    })

    return (<div { ...blockProps }>Exported Edit</div>)
}

export const save = ({ attributes }: any) => {
    const { blockId } = attributes

    const blockProps = useBlockProps.save({
        className: `test-block-id-${ blockId }`,
    })

    return (<div { ...blockProps }>Exported Edit</div>)
}

registerBlockType(meta.name, {
    title: meta.title,
    icon: meta.icon,
    supports: meta.supports,
    category: meta.category,
    attributes: {
        blockId: { type: "string", default: 'test' },
        layout: { type: 'string', default: 'content' },
        test: { type: 'string', default: 'test' },
    },
    edit,
    save,
})

This block throws an error that I described above

import { useRef, useEffect } from 'react'
import { registerBlockType } from '@wordpress/blocks'

function useSetBlockIdEffect(clientId: string, setAttributes: Function) {
    useEffect(() => {
        setAttributes({ blockId: clientId })
    }, [clientId])
}

// Icons
import { ContainerIcon } from "@src/lib/block-icons"

import {
    useBlockProps,
} from '@wordpress/block-editor'

export const meta = {
    name: "test/block",
    title: "TestBlock",
    icon: ContainerIcon,
    apiVersion: 3,
    supports: {
        color: {
            background: false,
            gradients: true,
            text: true,
        },
        typography: {
            fontSize: true,
            lineHeight: true,
        },
        shadow: true,
    },
    category: "layout",
}

export const edit = ({ clientId, attributes, setAttributes }: any) => {
    const { blockId } = attributes
    console.log({ blockId, clientId, setAttributes })
    useSetBlockIdEffect(clientId, setAttributes)
    const containerRef = useRef<HTMLElement>(null)

    const blockProps = useBlockProps({
        className: `test-id-${ blockId }`,
    })

    return (<div { ...blockProps }>Exported Edit</div>)
}

export const save = ({ attributes }: any) => {
    const { blockId } = attributes

    const blockProps = useBlockProps.save({
        className: `test-block-id-${ blockId }`,
    })

    return (<div { ...blockProps }>Exported Edit</div>)
}

registerBlockType(meta.name, {
    title: meta.title,
    icon: meta.icon,
    supports: meta.supports,
    category: meta.category,
    attributes: {
        blockId: { type: "string", default: 'test' },
        layout: { type: 'string', default: 'content' },
        test: { type: 'string', default: 'test' },
    },
    edit,
    save,
})

I will test it in sage and see if it works in sage them and clean install in the moment then I will share a repo with you

I’m slightly confused by the config shared. Where is vite-plugin-react? Have you followed any documentation for using Vite with React? Also, your package.json contains upwards of 25 different Bud dependencies still.

I also suggest reading the README of https://github.com/roots/vite-plugin and understanding what it actually does and how you might need to adjust the config to match your entrypoint(s) for dependency extraction.

I am sorry, I am doing some custom stuff, but the issue seems to be not only isolated to my plugin, I have downloaded sage theme for test purposes and it demonstrates same type of behaviour.

Please see for yourselves, here are both plugin and sage theme representation.

Sage Theme

Plugin

https://github.com/asolopovas/test-plugin

I tried to keep them as minimal as possible.

I haven’t followed it but will try now will be funny, probably too tired to mis the basics

I have added react() plugin to vite config but have same behaviour. Laravel docs suggest
@viteReactRefresh to be added to head next to @vite, but that is not trivial task to do it on the Gutenberg page. I think you have to manually added it to the plugin, so that its loaded if that is why it doesn’t work with react hooks.

I’m sorry, but I’ve never attempted to use React outside of WordPress’ own bundled runtime. Let me know if you figure out a solution.

It was working with default bud installation out of the box, pretty well. I even managed to get HMR to work but its really slow, my CPU fans become loader when I work with bud.

Bud definitely had more fine-tuning for working with WordPress. Hopefully with support from the community, we can improve on the Vite plugin over time. Maintaining an entire build tool is an insane feat and tools like Vite have millions of dollars in funding.

I spent about 4 to 5 days just trying to switch to Vite. Initially, I attempted to write the plugin myself, but after struggling for a while, I couldn’t afford to spend more time on it. I came across your solution and thought it might work, but it looks like I’ll have to stick with Bud for now - at least until I make enough money to dedicate more time to side projects. Or until I find an investor who is willing to pay me millions…

Really appreciate your time guys, thank you for looking into it.

Would be great if someone from WordPress Team finally moved Gutenberg onto something more modern. Otherwise their build stack is total junk.