Build a Fully-Onchain Image Gallery
The FlowtoBooth tutorial series teaches you how to build a fun benchmark app and provides inspiration for the greater scope of possibilities building on Flow thanks to gas being so much less expensive.
It is not a production best-practice. While everything in these tutorials works, you'll run into the following problems at production scale:
- RPC Providers will likely rate-limit you for reading this much data at once
- NFT marketplaces may not display the images, likely due to the above
- 256*256 images are huge by blockchain data standards, but too small for modern devices
If you search for resources on how to store images of any significant size onchain, you'll be told it's either prohibitively expensive or even completely impossible. The reason for this is two-fold - first the size limit for data on transactions is about 40kb. Second, saving 40kb takes almost all of the 30 million gas limit on most blockchains.
The former constraint is immutable (though many chains are slowly increasing this limit), which limits the app to images about 256*256 pixels in size. The latter is heavily dependent on which chain you choose.
At current gas prices on most chains, using all 30 million gas in a block costs several dollars - or potentially thousands on ETH mainnet. At current prices on Flow, spending 30 million gas costs less than a penny, usually 1 or 2 tenths of a cent.
Much more computation is available at prices you or your users will be willing to pay for regular interactions. Including, but not limited to:
- Airdropping hundreds of NFTs with one transaction, for pennies
- Generation of large mazes
- Generating large amounts of random numbers (with free native VRF)
- Extensive string manipulation onchain
- Simple game AI logic
In this tutorial, we'll build a smart contract that can store and retrieve images onchain. We'll also build a simple frontend to interact with the contract on Flow and another chain.
Objectives
After completing this guide, you'll be able to:
- Construct a composable onchain image gallery that can be used permissionlessly by onchain apps and other contracts to store and retrieve images
- Build an onchain app that can interact with this contract to save and display images
- Compare the price of spending 30 million gas on Flow with the price on other chains
Prerequisites
Next.js and Modern Frontend Development
This tutorial uses Next.js. You don't need to be an expert, but it's helpful to be comfortable with development using a current React framework. You'll be on your own to select and use a package manager, manage Node versions, and other frontend environment tasks.
Solidity
You don't need to be an expert, but you should be comfortable writing code in Solidity. You can use Hardhat, Foundry, or even Remix.
Build an Image Gallery Contract
Start a new smart contract project in the toolchain of your choice and install the OpenZeppelin contracts.
In your project, stub out a new contract for your image gallery that inherits from the Ownable contract:
_10// ImageGallery.sol_10_10// SPDX-License-Identifier: MIT_10pragma solidity ^0.8.28;_10_10import "@openzeppelin/contracts/access/Ownable.sol";_10_10contract ImageGallery is Ownable {_10 constructor(address _owner) Ownable(_owner) {}_10}
We're passing the original owner of the contract as an argument in the constructor to give greater flexibility for ownership when this contract is deployed.
Set Up Storage for Images
We'll store the images in a simple struct
that holds the image as a base64
encoded string
and also contains a string
for the description. Doing so allows the image to be directly used in html and makes it easier to test the contract directly with a block explorer, but has the downside of making the images 33% bigger. Another format would be more efficient.
These will be held in array:
_10struct Image {_10 string description;_10 string base64EncodedImage;_10}_10_10Image[] public images;
Construct Functions to Add and Delete Images
Next, add a function that accepts a _description
and _base64EncodedImage
and adds them to the array.
_10function addImage(_10 string memory _description,_10 string memory _base64EncodedImage_10) public onlyOwner {_10 images.push(Image(_description, _base64EncodedImage));_10}
Then, add one to delete the image at a given index:
_10function deleteImage(uint256 index) public onlyOwner {_10 if (index >= images.length) {_10 revert ImageIndexOutOfBounds(index, images.length);_10 }_10 for (uint256 i = index; i < images.length - 1; i++) {_10 images[i] = images[i + 1];_10 }_10 images.pop();_10}
If the array gets big enough that calling deleteImage
takes more than 30 million gas, it will brick this function. A safer and more gas-efficient method is to use a mapping
with a counter as the index, and handling for the case where an index is empty.
We're doing it this way to provide a way to delete accidentally uploaded images without making things too complex.
Retrieval Functions
Finally, add functions to get one image, get all of the images, and get the number of images in the collection.
_14function getImages() public view returns (Image[] memory) {_14 return images;_14}_14_14function getImage(uint256 index) public view returns (Image memory) {_14 if (index >= images.length) {_14 revert ImageIndexOutOfBounds(index, images.length);_14 }_14 return images[index];_14}_14_14function getImageCount() public view returns (uint256) {_14 return images.length;_14}
Final Contract
After completing the above, you'll end up with a contract similar to:
_49// SPDX-License-Identifier: UNLICENSED_49pragma solidity ^0.8.28;_49_49import "@openzeppelin/contracts/access/Ownable.sol";_49_49contract ImageGallery is Ownable {_49 struct Image {_49 string description;_49 string base64EncodedImage;_49 }_49_49 Image[] public images;_49_49 error ImageIndexOutOfBounds(uint256 index, uint256 length);_49_49 constructor(address _owner) Ownable(_owner) {}_49_49 function addImage(_49 string memory _description,_49 string memory _base64EncodedImage_49 ) public onlyOwner {_49 images.push(Image(_description, _base64EncodedImage));_49 }_49_49 function deleteImage(uint256 index) public onlyOwner {_49 if (index >= images.length) {_49 revert ImageIndexOutOfBounds(index, images.length);_49 }_49 for (uint256 i = index; i < images.length - 1; i++) {_49 images[i] = images[i + 1];_49 }_49 images.pop();_49 }_49_49 function getImages() public view returns (Image[] memory) {_49 return images;_49 }_49_49 function getImage(uint256 index) public view returns (Image memory) {_49 if (index >= images.length) {_49 revert ImageIndexOutOfBounds(index, images.length);_49 }_49 return images[index];_49 }_49_49 function getImageCount() public view returns (uint256) {_49 return images.length;_49 }_49}
Create a Factory
The image gallery contract you've just constructed is intended to be a utility for other contracts and apps to use freely. You don't want just one gallery for everyone, you need to give the ability for any app or contract to create and deploy private galleries freely.
Build a factory to deploy image galleries:
_13pragma solidity ^0.8.28;_13_13import "@openzeppelin/contracts/access/Ownable.sol";_13import "./ImageGallery.sol";_13_13contract ImageGalleryFactory {_13 event ImageGalleryCreated(address indexed owner, address gallery);_13_13 function createImageGallery(address _owner) public {_13 ImageGallery gallery = new ImageGallery(_owner);_13 emit ImageGalleryCreated(_owner, address(gallery));_13 }_13}
Tracking Factories
Some app designs may need multiple galleries for each user. For example, you might want to be able to give users the ability to collect images in separate galleries for separate topics, dates, or events, similar to how many photo apps work on smartphones.
To facilitate this feature, update your contract to keep track of which galleries have been created by which users. You'll end up with:
_23// SPDX-License-Identifier: UNLICENSED_23pragma solidity ^0.8.28;_23_23import "@openzeppelin/contracts/access/Ownable.sol";_23import "./ImageGallery.sol";_23_23contract ImageGalleryFactory {_23 event ImageGalleryCreated(address indexed owner, address gallery);_23_23 mapping(address => address[]) userToGalleries;_23_23 function createImageGallery(address _owner) public {_23 ImageGallery gallery = new ImageGallery(_owner);_23 emit ImageGalleryCreated(_owner, address(gallery));_23 userToGalleries[_owner].push(address(gallery));_23 }_23_23 function getGalleries(_23 address _owner_23 ) public view returns (address[] memory) {_23 return userToGalleries[_owner];_23 }_23}
Testing the Factory
Write appropriate unit tests, then deploy and verify the factory on Flow Testnet.
If you need help, check out:
Navigate to evm-testnet.flowscan.io, search for your contract, and navigate to the contracts
tab, then Read/Write contract
. You'll see something similar to:
Connect
your wallet. Use the Flow Wallet if you want automatically sponsored gas on both mainnet and testnet, or use the Flow Faucet to grab some testnet funds if you prefer to use another wallet.
Expand the createImageGallery
function, click the self
button, and then Write
the function.
Approve the transaction and wait for it to complete. Then, call getGalleries
for your address to find the address of the gallery you've created.
Testing the Image Gallery
Search for the address of your image gallery contract. It won't
be verified, but if you're using our exact contract, you will see a message from Flowscan that a verified contract with the same bytecode was found in the Blockscout DB. Click the provided link to complete the verification process.
The easiest way to get an ABI for the image gallery is to deploy one. You can do that now if you like.
If you're following along, but used your own contract, simply deploy and verify one copy of the contract directly, refresh the page, then complete the above.
You could test addImage
with a random string, but it's better to use a base64-encoded image. Search for and navigate to one of the many online tools that will base64 encode images.
Most sites of this nature are free tools created by helpful programmers and are funded with ads, donations, or the generosity of the creator. But you never know who made them or what they're caching.
Never upload or convert sensitive data on a free site.
Use the tool to convert an image that is ~30kb or smaller. Copy the string and paste it into the field in addImage
. You can also add a description
, but the bytes used will count towards the ~40kb limit.
Click Write
and approve the transaction. Take note of the cost! You've saved an image onchain forever for just a little bit of gas!
Once the transaction goes through, call getImage
with 0
as the index to retrieve your description and base64-encoded image.
Paste your image string as the src
for an img
tag in an html snippet to confirm it worked.
_10<div>_10 <img_10 src="data:image/png;base64,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"_10 />_10</div>
Building the Frontend
Now that your contracts are sorted and working, it's time to build an app to interact with it. We'll use Next.js for this, but the components we provide will be adaptable to other React frameworks.
Run:
_10npx create-next-app
We're using the default options.
Next, install rainbowkit, wagmi, and their related dependencies:
_10npm install @rainbow-me/rainbowkit wagmi viem@2.x @tanstack/react-query
Provider Setup
Add a file called providers
inside the app
folder. In it, add your config and providers for wagmi and rainbowkit. You'll need to add the Flow Wallet as a custom wallet. It's not included by default because it has special features that aren't compatible with other blockchains.
_113'use client';_113_113import { connectorsForWallets } from '@rainbow-me/rainbowkit';_113import { Wallet, getWalletConnectConnector } from '@rainbow-me/rainbowkit';_113import { QueryClient, QueryClientProvider } from '@tanstack/react-query';_113import { createConfig, WagmiProvider } from 'wagmi';_113import { RainbowKitProvider } from '@rainbow-me/rainbowkit';_113import { flowTestnet } from 'viem/chains';_113import { http } from 'wagmi';_113_113const projectId = '51407fcf066d74968d9a1a4c6da0d994'; // Replace with your actual project ID_113_113export interface MyWalletOptions {_113 projectId: string;_113}_113_113const flowWallet = ({ projectId }: MyWalletOptions): Wallet => ({_113 id: 'flow-wallet',_113 name: 'Flow Wallet',_113 iconUrl: 'https://lilico.app/logo_mobile.png',_113 iconBackground: '#41CC5D',_113 downloadUrls: {_113 android:_113 'https://play.google.com/store/apps/details?id=com.flowfoundation.wallet',_113 ios: 'https://apps.apple.com/ca/app/flow-wallet-nfts-and-crypto/id6478996750',_113 chrome:_113 'https://chromewebstore.google.com/detail/flow-wallet/hpclkefagolihohboafpheddmmgdffjm',_113 qrCode: 'https://link.lilico.app',_113 },_113 mobile: {_113 getUri: (uri: string) => uri,_113 },_113 qrCode: {_113 getUri: (uri: string) => uri,_113 instructions: {_113 learnMoreUrl: 'https://wallet.flow.com',_113 steps: [_113 {_113 description:_113 'We recommend putting Flow Wallet on your home screen for faster access to your wallet.',_113 step: 'install',_113 title: 'Open the Flow Wallet app',_113 },_113 {_113 description:_113 'You can find the scan button on home page, a connection prompt will appear for you to connect your wallet.',_113 step: 'scan',_113 title: 'Tap the scan button',_113 },_113 ],_113 },_113 },_113 extension: {_113 instructions: {_113 learnMoreUrl: 'https://wallet.flow.com',_113 steps: [_113 {_113 description:_113 'We recommend pinning Flow Wallet to your taskbar for quicker access to your wallet.',_113 step: 'install',_113 title: 'Install the Flow Wallet extension',_113 },_113 {_113 description:_113 'Be sure to back up your wallet using a secure method. Never share your secret phrase with anyone.',_113 step: 'create',_113 title: 'Create or Import a Wallet',_113 },_113 {_113 description:_113 'Once you set up your wallet, click below to refresh the browser and load up the extension.',_113 step: 'refresh',_113 title: 'Refresh your browser',_113 },_113 ],_113 },_113 },_113 createConnector: getWalletConnectConnector({ projectId }),_113});_113_113const connectors = connectorsForWallets(_113 [_113 {_113 groupName: 'Recommended',_113 wallets: [flowWallet],_113 },_113 ],_113 {_113 appName: 'Onchain Image Gallery',_113 projectId: projectId,_113 },_113);_113_113const wagmiConfig = createConfig({_113 connectors,_113 chains: [flowTestnet],_113 ssr: true,_113 transports: {_113 [flowTestnet.id]: http(),_113 },_113});_113_113export default function Providers({ children }: { children: React.ReactNode }) {_113 const queryClient = new QueryClient();_113_113 return (_113 <WagmiProvider config={wagmiConfig}>_113 <QueryClientProvider client={queryClient}>_113 <RainbowKitProvider>{children}</RainbowKitProvider>_113 </QueryClientProvider>_113 </WagmiProvider>_113 );_113}
Add the Connect Button
Open page.tsx
and clear out the default content. Replace it with a message about what your app does and add the rainbowkit Connect
button. Don't forget to import rainbowkit's css file and the ConnectButton
component:
_25import '@rainbow-me/rainbowkit/styles.css';_25_25import { ConnectButton } from '@rainbow-me/rainbowkit';_25_25export default function Home() {_25 return (_25 <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">_25 <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">_25 <h1 className="text-4xl font-bold">Image Gallery</h1>_25 <p className="text-lg text-center sm:text-left">_25 A decentralized image gallery built on Flow blockchain. All images_25 saved directly onchain._25 </p>_25 <p className="text-lg text-center sm:text-left">_25 A fun benchmark, not best practice for production!_25 </p>_25 <p className="text-lg text-center sm:text-left">_25 Free with gas sponsored by Flow with the Flow wallet. Sub-cent to save_25 an image with other wallets._25 </p>_25 <ConnectButton />_25 </main>_25 </div>_25 );_25}
Test the app and make sure you can connect your wallet.
Import Your Contracts
Next, you'll need to get your contract ABI and address into your frontend. If you're using Hardhat, you can use the artifacts produced by the Ignition deployment process. If you're using Foundry or Remix, you can adapt this process to the format of artifacts produced by those toolchains.
If you didn't deploy the Image Gallery contract, do so now to generate an artifact containing the ABI.
Add a folder in app
called contracts
. Copy the following files from your smart contract project, located in the ignition
and ignition/deployments/chain-545
folders:
deployed_addresses.json
ImageGallery#ImageGallery.json
ImageGalleryFactory#ImageGalleryFactory.json
Additionally, add a file called contracts.ts
. In it, create a hook to provide the ABI and addresses of your contracts conveniently:
_22import { useMemo } from 'react';_22import { Abi } from 'viem';_22_22import imageGalleryFactory from './ImageGalleryFactory#ImageGalleryFactory.json';_22import imageGallery from './ImageGallery#ImageGallery.json';_22import addresses from './deployed_addresses.json';_22_22export default function useContracts() {_22 return useMemo(() => {_22 return {_22 imageGalleryFactory: {_22 address: addresses[_22 'ImageGalleryFactory#ImageGalleryFactory'_22 ] as `0x${string}`,_22 abi: imageGalleryFactory.abi as Abi,_22 },_22 imageGallery: {_22 abi: imageGallery.abi as Abi,_22 },_22 };_22 }, []);_22}
Note that we're not including an address
for the imageGallery
itself. We'll need to set this dynamically as users might have more than one gallery.
Add Content
You can use a few strategies to organize the components that interact with the blockchain. One is to create a centralized component that stores all of the state related to smart contracts and uses a single instance of useWriteContract
. Doing so makes it easier to convey the transaction lifecycle to your users, at the cost of re-fetching all the data from your RPC provider after every transaction. This becomes sub-optimal if your app interacts with many contracts, or even different read functions within the same contract.
Add a folder in app
called components
, and create a file called Content.tsx
. In it, add the following:
- Imports for React, wagmi, your contracts, and Tanstack
- State variables for:
- When a reload is needed
- When you are waiting on a transaction response
- The list of gallery addresses for the connected wallet
- Hooks for:
useAccount()
useQueryClient()
useContracts()
useWriteContract()
useWaitForTransactionReceipt()
useEffects
to:- Listen for a receipt and set
reload
to true andawaitingResponse
false - Listen for needing a reload and invalidating the query for galleryAddresses
- Error handling
- Receipt of gallery addresses
- Listen for a receipt and set
- A
useReadContract
to fetch the list of gallery addresses for this user - Frontend code to display the button to create a gallery if the user is signed in
You'll end up with something similar to:
_103'use client';_103_103import { useEffect, useState } from 'react';_103import {_103 useAccount,_103 useReadContract,_103 useWaitForTransactionReceipt,_103 useWriteContract,_103} from 'wagmi';_103import useContracts from '../contracts/contracts';_103import { useQueryClient } from '@tanstack/react-query';_103_103export default function Content() {_103 const [reload, setReload] = useState(false);_103 const [awaitingResponse, setAwaitingResponse] = useState(false);_103 const [galleryAddresses, setGalleryAddresses] = useState<string[]>([]);_103_103 const account = useAccount();_103 const queryClient = useQueryClient();_103 const { imageGalleryFactory } = useContracts();_103_103 const { data, writeContract, error: writeError } = useWriteContract();_103_103 const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({_103 hash: data,_103 });_103_103 useEffect(() => {_103 if (receipt) {_103 setReload(true);_103 setAwaitingResponse(false);_103 }_103 }, [receipt]);_103_103 useEffect(() => {_103 if (reload) {_103 setReload(false);_103 queryClient.invalidateQueries({ queryKey: galleryAddressesQueryKey });_103 }_103 }, [reload]);_103_103 useEffect(() => {_103 if (writeError) {_103 console.error(writeError);_103 setAwaitingResponse(false);_103 }_103 }, [writeError]);_103_103 useEffect(() => {_103 if (receiptError) {_103 console.error(receiptError);_103 setAwaitingResponse(false);_103 }_103 }, [receiptError]);_103_103 const { data: galleryAddressesData, queryKey: galleryAddressesQueryKey } =_103 useReadContract({_103 abi: imageGalleryFactory.abi,_103 address: imageGalleryFactory.address,_103 functionName: 'getGalleries',_103 args: [account.address],_103 });_103_103 useEffect(() => {_103 if (galleryAddressesData) {_103 const newAddresses = galleryAddressesData as string[];_103 newAddresses.reverse();_103 setGalleryAddresses(newAddresses);_103 }_103 }, [galleryAddressesData]);_103_103 function handleCreateGallery() {_103 setAwaitingResponse(true);_103 writeContract({_103 abi: imageGalleryFactory.abi,_103 address: imageGalleryFactory.address,_103 functionName: 'createImageGallery',_103 args: [account.address],_103 });_103 }_103_103 return (_103 <div className="card gap-1">_103 {account.isConnected && (_103 <div>_103 <div className="mb-4">_103 <button_103 onClick={handleCreateGallery}_103 disabled={awaitingResponse}_103 className={`px-4 py-2 rounded-lg text-white ${_103 !awaitingResponse_103 ? 'bg-blue-500 hover:bg-blue-600'_103 : 'bg-gray-300 cursor-not-allowed'_103 }`}_103 >_103 {awaitingResponse ? 'Processing...' : 'Create Gallery'}_103 </button>_103 </div>_103 </div>_103 )}_103 </div>_103 );_103}
Don't forget to add your <Content />
component to page.tsx
, below the <ConnectButton />
component.
Test the app and make sure you can complete the transaction to create a gallery.
Gallery List
Next, you'll need to display the list of a user's galleries and enable them to select which one they want to interact with. A dropdown list will serve this function well. Add a component called AddressList.tsx
, and in it add:
_42import React, { useEffect, useState } from 'react';_42_42type AddressDropdownProps = {_42 addresses: string[]; // Array of EVM addresses_42 handleSetActiveAddress: Function;_42};_42_42const AddressDropdown: React.FC<AddressDropdownProps> = ({_42 addresses,_42 handleSetActiveAddress,_42}) => {_42 const [selectedAddress, setSelectedAddress] = useState('');_42_42 useEffect(() => {_42 if (selectedAddress) {_42 console.log(selectedAddress);_42 handleSetActiveAddress(selectedAddress);_42 }_42 }, [selectedAddress]);_42_42 return (_42 <div className="container mx-auto px-4">_42 <h1 className="text-2xl font-bold text-center mb-6">Select a Gallery</h1>_42 <div className="flex flex-col items-center space-y-4">_42 <select_42 value={selectedAddress}_42 onChange={(e) => setSelectedAddress(e.target.value)}_42 className="w-full max-w-md border border-gray-300 rounded-lg p-2 bg-white shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500"_42 >_42 <option value="">Select an address</option>_42 {addresses.map((address, index) => (_42 <option key={index} value={address}>_42 {address}_42 </option>_42 ))}_42 </select>_42 </div>_42 </div>_42 );_42};_42_42export default AddressDropdown;
This component doesn't interact directly with the blockchain. It accepts the array of addresses
and a function to handle setting the activeAddress
.
To use it in Content.tsx
, you'll need to add a new state variable for the activeAddress
:
_10const [activeAddress, setActiveAddress] = useState<string | null>(null);
You'll also need a handler for when the activeAddress
is set. You can't just use the setActiveAddress()
function because you need to tell the app to reload if the user changes which gallery is active, so that the images in that gallery are loaded.
_10function handleSetActiveAddress(address: string) {_10 setReload(true);_10 setActiveAddress(address);_10}
Finally, add the new component under the <button>
:
_10<AddressList_10 addresses={galleryAddresses}_10 handleSetActiveAddress={handleSetActiveAddress}_10/>
Test again, and confirm that the address of the gallery you created is in the dropdown and is selectable. The provided code contains a console log as well, to make it easier to copy the address in case you need to check it on Flowscan.
Display the Images
Next, you need to pull the images for the selected gallery from the contract.
Make sure you're using the same gallery you added an image too earlier. Otherwise, there won't be an image to pull and display!
Create a component called ImageGallery
. All this needs to do is accept a list of images and descriptions and display them. You can style this nicely if you'd like, or use the basic implementation here:
_55export type ImageGalleryImage = {_55 description: string;_55 base64EncodedImage: string;_55};_55_55type ImageGalleryProps = {_55 images: ImageGalleryImage[]; // Array of image objects_55};_55_55const ImageGallery: React.FC<ImageGalleryProps> = ({ images }) => {_55 if (images.length === 0) {_55 return (_55 <div className="container mx-auto px-4">_55 <p className="text-center text-xl font-bold">No images to display</p>_55 </div>_55 );_55 }_55_55 return (_55 <div className="container mx-auto px-4">_55 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">_55 {images.map((image, index) => {_55 const isValidBase64Image =_55 typeof image.base64EncodedImage === 'string' &&_55 image.base64EncodedImage.startsWith('data:image/') &&_55 image.base64EncodedImage.includes('base64,');_55_55 return (_55 <div_55 key={index}_55 className="border border-gray-200 rounded-lg overflow-hidden shadow-md"_55 >_55 {isValidBase64Image ? (_55 <img_55 src={image.base64EncodedImage}_55 alt={image.description || `Image ${index + 1}`}_55 className="w-full h-auto object-cover"_55 />_55 ) : (_55 <div className="p-4 text-center text-red-500">_55 Invalid image data_55 </div>_55 )}_55 <div className="p-2 bg-gray-100 text-center text-sm text-gray-700">_55 {image.description || 'No description available'}_55 </div>_55 </div>_55 );_55 })}_55 </div>_55 </div>_55 );_55};_55_55export default ImageGallery;
Implementing the gallery display will take more additions to Content.tsx
. You'll need to:
- Add a state variable for the list of images
- Implement a second
useContractRead
hook to pull the images from the currently selected gallery address - Hook the gallery into the refresh logic
First, add the state variable to store the gallery array:
_10const [images, setImages] = useState<ImageGalleryImage[]>([]);
Next, add a useReadContract
to read from the gallery. Use the activeAddress
for the address
property. Don't forget to destructure imageGallery
from useContracts
_10const [images, setImages] = useState<ImageGalleryImage[]>([]);
_10const { data: galleryData, queryKey: galleryQueryKey } = useReadContract({_10 abi: imageGallery.abi,_10 address: activeAddress as `0x${string}`,_10 functionName: 'getImages',_10});
Hook the new query key into the refresh system:
_10useEffect(() => {_10 if (reload) {_10 setReload(false);_10 queryClient.invalidateQueries({ queryKey: galleryAddressesQueryKey });_10 // Added to existing `useEffect`_10 queryClient.invalidateQueries({ queryKey: galleryQueryKey });_10 }_10}, [reload]);
Then, add a useEffect
to update the images
in state when galleryData
is received. Users expect the newest images to be shown first, so reverse
the array before setting it to state.
_10useEffect(() => {_10 if (galleryData) {_10 const newImages = galleryData as ImageGalleryImage[];_10 // reverse the array so the latest images are shown first_10 newImages.reverse();_10 setImages(newImages);_10 }_10}, [galleryData]);
Finally, implement the gallery itself in the return
:
_28return (_28 <div className="card gap-1">_28 {account.isConnected && (_28 <div>_28 <div className="mb-4">_28 <button_28 onClick={handleCreateGallery}_28 disabled={awaitingResponse}_28 className={`px-4 py-2 rounded-lg text-white ${_28 !awaitingResponse_28 ? 'bg-blue-500 hover:bg-blue-600'_28 : 'bg-gray-300 cursor-not-allowed'_28 }`}_28 >_28 {awaitingResponse ? 'Processing...' : 'Create Gallery'}_28 </button>_28 <AddressList_28 addresses={galleryAddresses}_28 handleSetActiveAddress={handleSetActiveAddress}_28 />_28 </div>_28 <div className="mb-4">_28 <ImageGallery images={images} />_28 </div>_28 </div>_28 )}_28 </div>_28);
Run the app, log in with your wallet that has the gallery you created for testing and select the gallery.
You're now displaying an image that is stored onchain forever!
Image Uploader
The last thing to do for this initial implementation is to add functionality so that users can upload their own images through the app and save them onchain without needing to do the base64 conversion on their own.
For now, we'll just generate an error if the file is too big, but later on we can do that for the user as well.
Add the ImageUploader
component. This needs to handle uploading the image and displaying any errors. We'll keep the state for the image itself in Content
so that it's accessible to other components:
_64import React, { useState } from 'react';_64_64type ImageUploaderProps = {_64 setUploadedBase64Image: (base64: string) => void; // Function to set the uploaded base64 image_64};_64_64const ImageUploader: React.FC<ImageUploaderProps> = ({_64 setUploadedBase64Image,_64}) => {_64 const [error, setError] = useState<string | null>(null);_64_64 const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {_64 const file = event.target.files?.[0];_64_64 if (!file) {_64 setError('No file selected');_64 return;_64 }_64_64 if (!file.type.startsWith('image/')) {_64 setError('Only image files are allowed');_64 return;_64 }_64_64 if (file.size > 30 * 1024) {_64 setError('Image size must be 30KB or smaller');_64 return;_64 }_64_64 const reader = new FileReader();_64 reader.onload = () => {_64 const base64 = reader.result as string;_64 setUploadedBase64Image(base64);_64 setError(null);_64 };_64 reader.onerror = () => {_64 setError('Failed to read file');_64 };_64 reader.readAsDataURL(file);_64 };_64_64 return (_64 <div className="container mx-auto px-4">_64 <div className="flex flex-col items-center space-y-4">_64 <label_64 htmlFor="image-upload"_64 className="cursor-pointer bg-blue-500 text-white px-4 py-2 rounded-lg shadow-md hover:bg-blue-600"_64 >_64 Upload Image_64 </label>_64 <input_64 id="image-upload"_64 type="file"_64 accept="image/*"_64 onChange={handleImageUpload}_64 className="hidden"_64 />_64 {error && <p className="text-red-500 text-sm">{error}</p>}_64 </div>_64 </div>_64 );_64};_64_64export default ImageUploader;
As before, we'll need to make some updates to Content.tsx
to complete the implementation.
First, add a state variable for the image:
_10const [uploadedBase64Image, setUploadedBase64Image] = useState<string>('');
Then add the ImageUploader
to the return
:
_10<ImageUploader setUploadedBase64Image={setUploadedBase64Image} />
Later on, you'll probably want to make a component for displaying the uploaded image, but for now just add it below the uploader button component:
_11{_11 uploadedBase64Image && (_11 <div className="mt-6 text-center">_11 <img_11 src={uploadedBase64Image}_11 alt="Uploaded"_11 className="max-w-xs mx-auto rounded-lg shadow-md"_11 />_11 </div>_11 );_11}
Finally, you need to add a button and a handler to call the smart contract function to save the image onchain.
_10function handleSaveOnchain() {_10 // console.log(uploadedBase64Image);_10 setAwaitingResponse(true);_10 writeContract({_10 abi: imageGallery.abi,_10 address: activeAddress as `0x${string}`,_10 functionName: 'addImage',_10 args: ['', uploadedBase64Image],_10 });_10}
Add the button inside the check for an uploadedBase64Image
so that it only displays when there is an image to upload:
_22{_22 uploadedBase64Image && (_22 <div className="mt-6 text-center">_22 <img_22 src={uploadedBase64Image}_22 alt="Uploaded"_22 className="max-w-xs mx-auto rounded-lg shadow-md"_22 />_22 <button_22 onClick={handleSaveOnchain}_22 disabled={awaitingResponse}_22 className={`px-4 py-2 rounded-lg text-white ${_22 !awaitingResponse_22 ? 'bg-blue-500 hover:bg-blue-600'_22 : 'bg-gray-300 cursor-not-allowed'_22 }`}_22 >_22 {awaitingResponse ? 'Loading...' : 'Save Onchain'}_22 </button>_22 </div>_22 );_22}
Test the app to save your new image, and make sure the error displays if you try to upload an image that is too large.
Conclusion
In this tutorial, you built a fully functional onchain image gallery using Flow EVM. You created smart contracts that can store images directly on the blockchain and a modern React frontend that allows users to interact with these contracts. The implementation demonstrates how Flow's efficient gas pricing makes operations that would be prohibitively expensive on other chains not just possible, but practical.
Now that you have completed the tutorial, you should be able to:
- Construct a composable onchain image gallery that can be used permissionlessly by onchain apps and other contracts to store and retrieve images
- Build an onchain app that can interact with this contract to save and display images
- Compare the price of spending 30 million gas on Flow with the price on other chains
Now that you've completed this tutorial, you're ready to explore more complex onchain storage patterns and build applications that take advantage of Flow's unique capabilities for storing and processing larger amounts of data than traditionally possible on other chains.