ACF Blocks (PHP-rendered) appear lightweight but suffer from latency during editor editsdue to server-side rendering on every state change. Native React Gutenberg Blocks use the Virtual DOM, enabling near-instant visual feedback and batch updates. On the frontend, both are equivalent if cached correctly. The choice hinges on complexity: >5 custom fields or dynamic relationships = React; simple layouts = ACF.
Hard Rule:
- Native React if: Custom fields >5, conditional visibility, repeating blocks with inner blocks, or API-driven content.
- ACF Blocks if: Static layouts ≤3 custom fields, no dynamic relationships, designer/client preference for PHP.
Part 1: Editor Experience—The Hidden Performance Tax
ACF Blocks: Server-Side Rendering in Edit Mode
When you edit an ACF Block in Gutenberg, here’s what happens:
Sequence (every keystroke/change):
- Editor triggers onChange → User types into an ACF field
- Gutenberg serializes attributes to post_content (HTML comment format)
- WordPress calls
render_callback(PHP function) on the server - Full PHP execution → ACF loads field definitions, queries database if needed
- PHP returns HTML to editor
- Editor replaces block DOM with new HTML
- All child elements unmount/remount → Event listeners rebound
DOM Impact:
- Full block’s DOM is replaced (not just changed attributes).
- Any focused form input loses focus (UX pain point).
- Scroll position may reset if block is large.
- All inline event handlers (onClick, onInput) are re-registered.
Timeline Example (ACF Block with 8 fields):
text[User types in field]
↓ 50ms (Gutenberg serialization)
↓ 200-500ms (server round-trip + PHP bootstrap)
↓ 300ms (render_callback execution)
↓ 150ms (HTML → DOM replacement, reflow/repaint)
↓ 100ms (re-register event listeners)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TOTAL: 800ms - 1200ms latency per keystroke
Result: Editor feels laggy. Users perceive a 0.8s+ delay before seeing their text appear.
Native React Blocks: Virtual DOM with Immediate Reconciliation
React blocks are re-rendered locally in the browser using the Virtual DOM.
Sequence (every keystroke):
- User types → onChange handler fires locally (JavaScript)
- React state updates in memory (<1ms)
- Virtual DOM re-renders from scratch (in-memory, no DOM touch)
- React diffing algorithm compares old vDOM with new vDOM
- React patches only changed nodes (usually 1-2 elements)
- Real DOM receives surgical updates (e.g., just
textContent = "new value") - Browser reflows/repaints only affected regions (≤10ms)
Timeline Example (React block with 8 fields):
text[User types in field]
↓ 0.5ms (onChange state update)
↓ 1-2ms (Virtual DOM render)
↓ 2-3ms (React diffing algorithm)
↓ 1-2ms (Real DOM patch - textContent update only)
↓ 5-10ms (Browser reflow for input field region)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TOTAL: 10-20ms latency per keystroke
Result: Feels instantaneous. Typing is smooth; UI responds within browser’s perception threshold (<50ms).
Part 2: Frontend Experience—Attributes vs. Meta Queries
ACF Blocks on Frontend
ACF Blocks store data as JSON-serialized attributes within the post_content’s HTML comment block:
xml<!-- wp:acf/testimonial {
"name": "John Doe",
"testimonialText": "Amazing product!",
"rating": 5
} /-->
No database queries needed; attributes are parsed once during post fetch.
Performance:
- Parse post_content → extract JSON comment → decode attributes: ~2ms.
- No postmeta queries (if block data stored in attributes).
- Exception: If block uses
render_callbackwithget_field('acf_field', $post_id), it queries postmeta (defeats the purpose).
Native React Blocks on Frontend
React blocks also store attributes in post_content. Additional data can come from:
- Block attributes only (same as ACF).
- REST API (fetch via
<ServerSideRender>component—avoid in favor of static rendering). - Statically rendered PHP (equivalent to ACF).
Performance:
- Parsing and rendering identical to ACF if properly implemented.
- Risk: Lazy developers add
useEffect+ fetch, which blocks rendering (bad).
Part 3: Code Comparison
Snippet A: ACF Block Registration
php<?php
declare(strict_types=1);
namespace MyCompany\Blocks;
class TestimonialBlock {
public static function register(): void {
// Define block registration
acf_register_block_type([
'name' => 'testimonial',
'title' => 'Testimonial',
'description' => 'Customer testimonial with rating',
'render_template' => dirname(__FILE__) . '/testimonial.php',
'category' => 'common',
'icon' => 'admin-comments',
'keywords' => ['testimonial', 'review', 'quote'],
// ACF Fields (this is where the pain point begins)
'fields' => [
[
'key' => 'field_testimonial_name',
'name' => 'name',
'label' => 'Customer Name',
'type' => 'text',
],
[
'key' => 'field_testimonial_text',
'name' => 'testimonial_text',
'label' => 'Testimonial Text',
'type' => 'textarea',
'rows' => 4,
],
[
'key' => 'field_testimonial_rating',
'name' => 'rating',
'label' => 'Rating (1-5)',
'type' => 'range',
'min' => 1,
'max' => 5,
],
[
'key' => 'field_testimonial_image',
'name' => 'author_image',
'label' => 'Author Photo',
'type' => 'image',
'return_format' => 'array',
],
],
'supports' => [
'align' => false,
'anchor' => true,
],
]);
}
}
// Hook into WordPress
add_action('acf/init', [TestimonialBlock::class, 'register']);
Frontend Template (testimonial.php):
php<?php
declare(strict_types=1);
// $block array provided by ACF, contains attributes parsed from post_content
$name = $block['data']['name'] ?? '';
$text = $block['data']['testimonial_text'] ?? '';
$rating = (int) ($block['data']['rating'] ?? 0);
$image = $block['data']['author_image'] ?? [];
?>
<div class="testimonial" data-rating="<?php echo esc_attr($rating); ?>">
<?php if (!empty($image)): ?>
<img
src="<?php echo esc_url($image['url']); ?>"
alt="<?php echo esc_attr($name); ?>"
class="testimonial__image"
loading="lazy"
width="<?php echo esc_attr($image['width'] ?? 100); ?>"
height="<?php echo esc_attr($image['height'] ?? 100); ?>"
/>
<?php endif; ?>
<blockquote class="testimonial__quote">
<p><?php echo wp_kses_post($text); ?></p>
</blockquote>
<p class="testimonial__author">
<strong><?php echo esc_html($name); ?></strong>
<span class="testimonial__rating" aria-label="<?php echo esc_attr($rating); ?> out of 5 stars">
<?php echo str_repeat('★', $rating) . str_repeat('☆', 5 - $rating); ?>
</span>
</p>
</div>
Editor Experience Issue: When user edits the name field, the entire testimonial.phptemplate is re-executed on the server, the HTML is regenerated, and the entire block DOM is replaced in Gutenberg. This causes ~800ms latency.
Snippet B: Native React Block (Equivalent Functionality)
Block Structure:
textblocks/
testimonial/
block.json
edit.js
save.js
editor.scss
render.php
block.json:
json{
"apiVersion": 3,
"name": "mycompany/testimonial",
"title": "Testimonial",
"category": "common",
"icon": "admin-comments",
"description": "Customer testimonial with rating",
"attributes": {
"name": {
"type": "string",
"default": ""
},
"testimonialText": {
"type": "string",
"default": ""
},
"rating": {
"type": "number",
"default": 5
},
"authorImageId": {
"type": "number",
"default": 0
},
"authorImageUrl": {
"type": "string",
"default": ""
}
},
"supports": {
"align": false,
"anchor": true,
"html": false
},
"textdomain": "mycompany"
}
edit.js (Editor experience—React component):
jsx// @wordpress/scripts handles transpilation
import { __ } = wp.i18n;
import {
TextControl,
TextareaControl,
RangeControl,
MediaUploadCheck,
MediaUpload,
} from '@wordpress/components';
import {
BlockControls,
InspectorControls,
useBlockProps,
} from '@wordpress/block-editor';
import { useState, useEffect } from '@wordpress/element';
export default function Edit(props) {
const {
attributes: {
name,
testimonialText,
rating,
authorImageId,
authorImageUrl
},
setAttributes,
} = props;
const [imageData, setImageData] = useState(null);
const blockProps = useBlockProps();
// Fetch image metadata if ID changes (deferred, not blocking)
useEffect(() => {
if (authorImageId && !imageData) {
// This runs AFTER render, doesn't block editor
fetch(`/wp-json/wp/v2/media/${authorImageId}`)
.then(res => res.json())
.then(data => {
setImageData(data);
setAttributes({ authorImageUrl: data.source_url });
})
.catch(err => console.error('Image fetch failed:', err));
}
}, [authorImageId]); // Only re-run if ID changes
return (
<>
<BlockControls>
{/* Toolbar options */}
</BlockControls>
<InspectorControls>
<div style={{ padding: '16px' }}>
<TextControl
label={__('Customer Name', 'mycompany')}
value={name}
onChange={(value) => setAttributes({ name: value })}
placeholder={__('John Doe', 'mycompany')}
/>
<TextareaControl
label={__('Testimonial Text', 'mycompany')}
value={testimonialText}
onChange={(value) => setAttributes({ testimonialText: value })}
rows={4}
placeholder={__('Write the testimonial...', 'mycompany')}
/>
<RangeControl
label={__('Rating', 'mycompany')}
value={rating}
onChange={(value) => setAttributes({ rating: value })}
min={1}
max={5}
marks
/>
<MediaUploadCheck>
<MediaUpload
onSelect={(media) => {
setAttributes({
authorImageId: media.id,
authorImageUrl: media.url,
});
setImageData(media);
}}
allowedTypes={['image']}
value={authorImageId}
render={({ open }) => (
<button
onClick={open}
style={{ marginTop: '10px', padding: '8px 12px' }}
>
{authorImageId
? __('Change Image', 'mycompany')
: __('Select Image', 'mycompany')
}
</button>
)}
/>
</MediaUploadCheck>
</div>
</InspectorControls>
{/* Live preview in editor (virtual DOM, not real DOM replace) */}
<div {...blockProps}>
<div className="testimonial">
{authorImageUrl && (
<img
src={authorImageUrl}
alt={name}
className="testimonial__image"
style={{ maxWidth: '100px', height: 'auto' }}
/>
)}
<blockquote className="testimonial__quote">
<p>{testimonialText || __('Testimonial text here...', 'mycompany')}</p>
</blockquote>
<p className="testimonial__author">
<strong>{name || __('Customer name...', 'mycompany')}</strong>
<span className="testimonial__rating">
{'★'.repeat(rating) + '☆'.repeat(5 - rating)}
</span>
</p>
</div>
</div>
</>
);
}
Key Differences (Why it’s faster):
- State updates are local → No server round-trip on keystroke.
- Virtual DOM diffing → Only changed attributes trigger DOM updates (e.g., text node update is ~1-2ms).
- useEffect defers image fetch → Doesn’t block initial render.
- InspectorControls auto-serialize → Gutenberg handles attribute persistence, not a full block re-render.
Latency per keystroke: ~10-20ms (feel instantaneous).
save.js (Frontend HTML output):
jsximport { useBlockProps } from '@wordpress/block-editor';
export default function Save(props) {
const {
attributes: {
name,
testimonialText,
rating,
authorImageUrl
},
} = props;
const blockProps = useBlockProps.save();
return (
<div {...blockProps}>
<div className="testimonial" data-rating={rating}>
{authorImageUrl && (
<img
src={authorImageUrl}
alt={name}
className="testimonial__image"
loading="lazy"
/>
)}
<blockquote className="testimonial__quote">
<p>{testimonialText}</p>
</blockquote>
<p className="testimonial__author">
<strong>{name}</strong>
<span className="testimonial__rating" aria-label={`${rating} out of 5 stars`}>
{'★'.repeat(rating) + '☆'.repeat(5 - rating)}
</span>
</p>
</div>
</div>
);
}
render.php (Optional: Server-side rendering for better SEO/performance):
php<?php
declare(strict_types=1);
$blockAttrs = $attributes ?? [];
$name = $blockAttrs['name'] ?? '';
$text = $blockAttrs['testimonialText'] ?? '';
$rating = (int) ($blockAttrs['rating'] ?? 0);
$imageUrl = $blockAttrs['authorImageUrl'] ?? '';
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<div class="testimonial" data-rating="<?php echo esc_attr($rating); ?>">
<?php if (!empty($imageUrl)): ?>
<img
src="<?php echo esc_url($imageUrl); ?>"
alt="<?php echo esc_attr($name); ?>"
class="testimonial__image"
loading="lazy"
/>
<?php endif; ?>
<blockquote class="testimonial__quote">
<p><?php echo wp_kses_post($text); ?></p>
</blockquote>
<p class="testimonial__author">
<strong><?php echo esc_html($name); ?></strong>
<span class="testimonial__rating" aria-label="<?php echo esc_attr($rating); ?> out of 5 stars">
<?php echo str_repeat('★', $rating) . str_repeat('☆', 5 - $rating); ?>
</span>
</p>
</div>
</div>
Part 4: Decision Matrix (Hard Rules)
Rule 1: Choose Native React if ANY of these apply:
- Custom fields > 5 → React’s batched state updates shine vs. ACF’s per-field re-rendering.
- Conditional visibility → Hide/show fields based on other field values (requires JavaScript logic).
- Repeating blocks with inner blocks → Dynamic arrays of sub-blocks (ACF’s flexible content is slow at scale).
- API-driven attributes → Data fetches from REST or external APIs (React’s useEffect defers blocking).
- Real-time validation → Input validation with instant feedback (React state updates are instant).
- Drag-and-drop reordering → Native blocks integrate with Gutenberg’s drag-drop primitives.
- Full Site Editing (FSE) → Modern WordPress features require React blocks (ACF blocks feel hacky in FSE).
Example: A “Testimonial Carousel” with 3+ repeating testimonial cards, each with image, text, and rating → Use React.
Rule 2: Choose ACF Blocks if ALL of these apply:
- Custom fields ≤ 3 → Simple form inputs (ACF’s overhead becomes irrelevant).
- No dynamic visibility → All fields always shown.
- No inner blocks → Block is self-contained (no nested blocks).
- Static data → No API calls needed.
- Team prefers PHP → Non-JavaScript developers on the team.
- Rapid prototyping → ACF UI builder is faster for mockups than coding React.
- Legacy codebase → Existing ACF infrastructure to maintain.
Example: A “Hero Banner” with name, subtitle, and background image (3 fields) → Use ACF.
Part 5: Performance Metrics Summary
| Metric | ACF Block | React Block | Winner |
|---|---|---|---|
| Editor keystroke latency | 800-1200ms | 10-20ms | React (60-100x faster) |
| Field visibility toggle | 800ms (full re-render) | 1-2ms (state update) | React |
| Repeating field add | 1.5-2s per item | 50-100ms per item | React (15-20x faster) |
| Frontend TTFB | ~same (both parse attributes) | ~same | Tie |
| Frontend payload | ~same | ~same (if no bundle bloat) | Tie |
| JS bundle size | 0KB | +50KB (@wordpress/scripts) | ACF |
| Setup time | 5 min (UI builder) | 30-60 min (code + build) | ACF |
| Maintenance complexity | Low (config-driven) | High (custom React code) | ACF |
Part 6: Migration Path (If Starting with ACF)
If you’ve built 10 ACF blocks and want to migrate high-impact ones to React:
Priority Order:
- Blocks with >6 fields → Highest ROI (editor UX improvement).
- Blocks with conditional logic → Second priority.
- Blocks in frequent-edit templates → Third priority (most user pain).
- Leave simple blocks → Not worth refactoring effort.
Migration cost estimate:
- ACF → React conversion: ~4-6 hours per block (if straightforward).
- Testing + QA: +2-3 hours.
- Total: 6-9 hours per block.
ROI threshold: Only migrate if block is edited >100 times/year or causes measurable user complaints.
Conclusion
Native React Blocks win on editor experience (10-20ms vs. 800-1200ms latency). ACF Blocks trade editor speed for development simplicity. The hard rule is clear:
- >5 fields or dynamic logic → React is non-negotiable.
- ≤3 fields, static layout → ACF is justified.
- Anything in between → Measure your team’s pain; if editor lag is complained about, migrate.
Gutenberg’s future is React-first (Full Site Editing, block patterns, interactivity). ACF remains valuable for rapid prototyping and legacy support, but scaling complex sites requires React.