ReactコンポーネントをBubbleで利用する(HTMLエレメント編)

はじめに

Bubbleとは

Bubbleは、プログラミングの知識がなくてもWebアプリケーションを作成できるノーコードツールです。

データベースの設計からフロントエンドのデザイン、バックエンドの処理まで、全ての工程をGUIで行うことができます。

また、プラグインを使うことで、様々な機能を追加することができます。

自分でプラグインを作成することも可能で、APIを使って外部サービスと連携することなどもできます。

カスタムの挙動やプラグインの作成にはJavaScriptを使うことができ、基本的にはVanillaJSとjQuery、サーバーサイドはNode.jsでの実装になります。

なぜBubbleでReact?

BubbleをWebサービス開発に使っていると、よくBubbleでは作るのが難しい要件に遭遇します。

PoCやMVPなどであればBubbleで十分なケースが多いですが、プロジェクトの要件やスケジュールによっては、PoCやMVPで作成したものにそのまま機能追加をしていき本番で運用していくこともあります。

プラグインを作って対応するにしても、基本的にVanillaJSとjQueryでの実装になるため、使えるライブラリが限られてしまっていたり、実装が面倒だったりします。

そんな時にReactコンポーネントをBubbleで使えるようにすることで、Bubbleの開発をより快適にすることができます。

また、Reactで作成したコンポーネントを段階的にBubbleに組み込んでいくことで、Reactでの開発を進めながら、Bubbleの開発も進めることができるので、最終的にUIに関してはほぼシームレスにBubbleからReactプロジェクトに移行することもできます。

そこで今回はReactで作成したコンポーネントをBubbleで使えるようにする方法を紹介します。

今回利用する技術スタック (※執筆時点のバージョン)

  • React, ReactDOM @^18.2.0
  • TypeScript @^5
  • Vite @^5
  • shadcn/ui @0.8.0
  • TailwindCSS @^3.4.1
  • pnpm @^8.12.1

プロジェクト作成

基本的にはshadcn/uiのドキュメントの流れに沿っていくだけです。

Viteでプロジェクトを作成
pnpm create vite@latest

✔ Project name: … bubble-react-component-demo
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC


Tailwindの追加
cd bubble-react-component-demo
pnpm add -D tailwindcss postcss autoprefixer
pnpm dlx tailwindcss init -p


tsconfig.jsonにbaseUrlとpathsを追加してTypeScriptがpathを認識できるようにする
{
  "compilerOptions": {
    // ...
+   "baseUrl": ".",
+   "paths": {
+     "@/*": [
+       "./src/*"
+     ]
+   },
    // ...
  }
}


vite.config.tsを編集してViteがpathを認識できるようにする
pnpm add -D @types/node
+ import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [react()],
+ resolve: {
+   alias: {
+     "@": path.resolve(__dirname, "./src"),
+   },
+ },
})


shadcn/uiの設定を追加
pnpm dlx shadcn-ui@latest init

✔ Would you like to use TypeScript (recommended)? … yes
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? … src/index.css
✔ Would you like to use CSS variables for colors? … no
✔ Are you using a custom tailwind prefix eg. tw-? (Leave blank if not) … tw-
✔ Where is your tailwind.config.js located? … tailwind.config.js
✔ Configure the import alias for components: … @/components
✔ Configure the import alias for utils: … @/lib/utils
✔ Are you using React Server Components? … no
✔ Write configuration to components.json. Proceed? … yes

✔ Writing components.json...
✔ Initializing project...
✔ Installing dependencies...

Success! Project initialization completed. You may now add components.

ここでひとつ重要なのはTailwindのスタイルのprefixを指定することです。

これをしないと、Bubbleに組み込んだ際に他のスタイルと競合してしまいます。

Are you using a custom tailwind prefix eg. tw-? (Leave blank if not)

というyes/noクエスチョンなのに、yes/noで答えるのではなくprefixとして使用する文字列を回答する必要があります。(わかりづらい...)



これで一通りの開発準備は整いました。

shadcn/uiのコンポーネントをBubbleで利用できるようにビルドしていきます。

Bubble(VanillaJS)用にビルド

shadcn/uiからButtonを追加
pnpm dlx shadcn-ui@latest add button

/src/components/ui/以下にbutton.tsxが追加されます。


ビルドのエントリーファイル作成

ファイルパスは自由ですが、今回はsrc/entry.tsxに設定します。

import React from "react";
import ReactDOM from "react-dom/client";
import {
  Button as ShadcnButton,
  ButtonProps,
} from "@/components/ui/button";
import "./index.css";

const Button = (props: ButtonProps) => {
  let container: HTMLElement | null = null;
  let root: ReactDOM.Root | null = null;
  const component = () => <ShadcnButton {...props} />;

  const render = () => {
    if (!container) {
      container = document.createElement("div");
      root = ReactDOM.createRoot(container);
    }
    root?.render(
      <React.StrictMode>
        <Comp />
      </React.StrictMode>,
    );
    
    return container;
  }

  const mutate = <K extends keyof ButtonProps>(key: K, value: ButtonProps[K]) => {
    props[key] = value;
    render();
  }

  return { render, mutate };
}

export default {
  Button,
};

Bubbleから渡すpropsの更新用にmutate関数を追加しています。

React18からはReactDOM.createRootを使うようになりましたが、ルートコンポーネントのpropsの変更はroot.render()を再実行することで反映できます。

この挙動はuseStateなどのReact Hooksの更新と似ていて、必要な部分だけ再レンダリングすることができます。

もちろんもっとコンポーネントの内部のstateを更新する際は、コンポーネントの内部でuseStateを使うべきです。

詳しくはReactのドキュメントを参照してください。


vite.config.tsにビルドの設定を追加
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
+ define: { "process.env.NODE_ENV": "'production'" },
+ build: {
+   minify: true,
+   lib: {
+     entry: path.resolve(__dirname, "src/entry.tsx"),
+     name: "ShadcnUI",
+     formats: ["iife"],
+     fileName: (format) => `shadcn-ui.${format}.js`,
+   },
+   rollupOptions: {
+     external: ["react", "react-dom"],
+     output: {
+       globals: {
+         react: "React",
+         "react-dom": "ReactDOM",
+       },
+     },
+   },
+   sourcemap: true,
  },
})

formatsは、Bubbleで利用できるようにiife形式でビルドします。

iifeとはImmediately Invoked Function Expressionの略で、即時関数として実行される関数式のフォーマットです。Bubbleで利用するにはこちらの形式が適しています。

Reactで利用するライブラリにする場合はformatsはesになってくるでしょう。

ここら辺を深掘りしたい方は js モジュールフォーマット などで検索するかChatGPTに聞いてみてください。

nameで指定したShadcnUIはビルド後のグローバル変数名になり、Bubbleから利用する際にこの変数名でアクセスできます。

ReactやReactDOMは外部リソースとして読み込むため、externalに指定し、ビルド結果からは除外します。

また、こちら のEnvironment Variables に記載がある通り

ViteのLibrary Modeのビルドでは、外部リソース内のprocess.env.NODE_ENVは自動変換してくれないため、defineで明示的に定義する必要があります。

iife形式の場合process.envは使えないため、defineを設定しないとエラーになります。


ビルド
pnpm build

dist/以下に
- shadcn-ui.iife.js
- shadcn-ui.iife.js.map
- style.css

が作られます。

ファイル名はvite.config.tsのbuild.lib.fileNameで設定したものになります。

Bubbleに追加

ビルド結果のjsとcssをBubbleにアップロードしCDNを作成

BubbleのData -> File Managerからビルド結果のshadcn-ui.iife.jsstyle.cssをアップロードします。

アップロード後にファイルのリンクURLをコピーします。

bubble-upload-file


PageのヘッダーでReactと今回のコンポーネントCDNの読み込み

Page HTML HeaderにCDNリンクを追加します。

<link rel="stylesheet" href="style.cssのCDN url">
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="shadcn-ui.jsのCDN url">

bubble-page-header


BubbleのHTMLエレメントでButtonを追加
<div id="shadcn-button" />
<script>
let count = 0;
const buttonText = "Clicked: ";

const Button = ShadcnUI.Button({
  children: buttonText +  count,
  onClick: () => {
    count++;
    Button.mutate("children", buttonText + count);
  },
});

$("#shadcn-button").append(Button.render());
</script>

Bubbleのエディターを編集して、HTMLエレメントを追加します。

bubble-html-element

画面にボタンが追加されます。

shadcn-button-in-bubble

クリックするとカウントアップされます。

shadcn-button-after-click-in-bubble



コンポーネントの追加ができました。

別のコンポーネントを追加

Buttonだけでは寂しいので次はToastを追加してみます。

shadcn/uiからToastを追加
pnpm dlx shadcn-ui@latest add toast


エントリーファイルにToastを追加
import React from "react";
import ReactDOM from "react-dom/client";
import {
  Button as ShadcnButton,
  ButtonProps,
} from "@/components/ui/button";
import {
  Toaster as ShadcnToaster,
} from "@/components/ui/toaster";
import { toast } from "@/components/ui/use-toast";
import "./index.css";

const BaseFunc = (Comp: React.ElementType) => {
  let container: HTMLElement | null = null;
  let root: ReactDOM.Root | null = null;

  const render = () => {
    if (!container) {
      container = document.createElement("div");
      root = ReactDOM.createRoot(container);
    }
    root?.render(
      <React.StrictMode>
        <Comp />
      </React.StrictMode>,
    );
    
    return container;
  }

  return { render };
}

const Button = (props: ButtonProps) => {
  const component = () => <ShadcnButton {...props} />;
  const { render } = BaseFunc(component);

  const mutate = <K extends keyof ButtonProps>(key: K, value: ButtonProps[K]) => {
    props[key] = value;
    render();
  }

  return { render, mutate };
}

const Toaster = () => {
  return BaseFunc(ShadcnToaster);
}

export default {
  Button,
  Toaster,
  toast,
};

追加されたshadcn/uiのToasterコンポーネントと、toast関数をexportします。


ビルド&Bubbleに追加

先ほどの手順でビルドし、ビルド結果をBubbleにアップロードします。

そしてPageヘッダーで読み込んでいるCDNを新しくアップロードしたもののURLに置き換えます。


HTMLエレメントにToastを追加
<div id="shadcn-button" />
<div id="shadcn-toaster" />
<script>
let count = 0;
const buttonText = "Clicked: ";

const Button = ShadcnUI.Button({
  children: buttonText +  count,
  onClick: () => {
    count++;
    ShadcnUI.toast({
      title: "トースト",
      description: "Toasted: " + count,
      variant: "destructive"
    });
    Button.mutate("children", buttonText + count);
  },
});

const Toaster = ShadcnUI.Toaster();

$("#shadcn-button").append(Button.render());
$("#shadcn-toaster").append(Toaster.render());
</script>

Toasterを配置して、Buttonをクリックしたらtoastを実行するようにします。

見た目をわかりやすくするためにvariantをdestructiveにしています。

variantなどのpropsについて詳しく見たい方はshadcn/uiのドキュメント、またはコマンドで追加された/src/components/ui/toast.tsxを参照してください。

bubble-html-element-button-toast

ボタンをクリックするとトーストが表示されます。

shadcn-toast-in-bubble



Toastの追加もできました。

まとめ

BubbleでReactコンポーネントを使う方法を紹介しました。

今回は例として既に綺麗にスタイリングされているshadcn/uiを使っていますが、

特別shadcn/uiやBubbleだからということではなく、やっていることはいわゆるReactコンポーネントをVanillaJSで使えるようにするっていうことですね。

もちろんjsのリソースサイズやパフォーマンスの問題もあるので、Bubbleに組み込む際も検証しながら進めていくといいと思います。

この記事では単純にBubbleのHTMLエレメントでReactコンポーネントを利用する方法を紹介しましたが、

次の記事では今回のコンポーネントをBubbleのプラグインとして利用する方法を紹介していきます。

最後に

IOではBubbleやFlutterFlowようなノーコードツールを使う機会を作ることもできます。

これからのAI時代、エンジニアがほとんどコーディングする必要がなくなる未来もそう遠くないかもしれません。

どんどん新しい技術やツールが出てくるので、常にトレンドをキャッチアップして新しいものを使いこなしていきたいですね。

IOでは常にエンジニアを積極採用中です!

少しでも興味を持っていただける方がいましたら、是非カジュアル面談からでも!

よろしくお願いします!

herp.careers herp.careers