Conditional Rendering, Fallbacks, and Empty States
Robust ACF templates treat missing data as a first-class state, not an exceptional bug.
You will design explicit fallback precedence rules, prevent empty wrapper rendering, and implement edge-case guards for partially configured ACF Pro structures. This is crucial because production content is rarely complete, especially during drafts, migrations, and cross-team publishing.
Concept Overview
Conditional rendering answers one question repeatedly: "Do we have enough data to render this block safely?" If yes, render normal output. If no, choose a fallback source or hide the block. The key is that this policy must be intentional and consistent across templates.
Fallback logic is not one-size-fits-all. Some values should inherit global defaults (phone numbers, legal footer text, default CTA label). Others should hide the section entirely when missing (testimonial quote, social icon cluster, optional trust badges). Mixing these policies without rules creates unpredictable UX.
Empty-state design protects both user experience and operational stability. When fields are absent, your template should avoid broken markup, avoid noisy warnings, and surface observability signals (logs/CLI checks) so teams can improve content completeness over time.
For each field-driven block, define one of three explicit outcomes: render value, render fallback, or hide block.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
| Define fallback precedence per component | Rendering behavior stays predictable under partial data | Better UX consistency and lower support load |
| Hide optional blocks when data is empty | Pages avoid blank placeholders and broken wrappers | Cleaner design integrity during content drafts |
| Use option-level defaults for global values | Shared components stay functional when page fields are missing | Faster publishing with fewer manual edits |
| Log fallback/empty-state events for audit | Teams can measure content quality drift | Better editorial feedback loops |
| Assume fields are always populated (wrong pattern) | Components break silently on draft or migrated pages | Production defects, conversion drop, and emergency hotfixes |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
get_field() | `get_field(string $selector, int | string $post_id = false, bool $format_value = true): mixed` | Retrieve primary and fallback values |
| Option fallback read | get_field('field_name', 'option') | Load global default value | ACF Pro Required in options-page workflows |
have_rows() | `have_rows(string $selector, int | string $post_id = false): bool` | Detect whether repeatable data exists |
array_filter() | array_filter(array $array, ?callable $callback = null): array | Remove empty values before rendering clusters | Useful for social links/icon sets |
empty() / strict checks | empty($value) / $value !== '' | Determine render eligibility | Prefer explicit checks for critical fields |
wp_kses_post() | wp_kses_post(string $content): string | Render safe rich-text fallback content | Use for controlled WYSIWYG fields |
error_log() | error_log(string $message): bool | Track fallback/hide events in logs | Useful for post-launch quality monitoring |
wp eval | wp eval '<php-code>' | Verify fallback sources and effective values | Required for deterministic QA in CLI workflows |
Practical Use Cases
Use Case 1 — Global CTA phone fallback (page-level → option-level → hide)
A service template should display a call CTA. Priority order is: page-specific number first, then global support number from options, and if both are missing, hide the CTA entirely.
- Read page-level phone field.
- Read option-level fallback phone.
- Resolve effective value using precedence.
- Render tel-link only when effective value exists.
- Log source used (
page,option, ornone) for observability.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
if (!function_exists('acf_add_options_page')) {
return;
}
acf_add_options_page([
'page_title' => 'Global CTA Defaults',
'menu_title' => 'Global CTA',
'menu_slug' => 'clinic-global-cta-defaults',
'capability' => 'manage_options',
'redirect' => false,
]);
});
add_action('template_redirect', function (): void {
if (!is_page()) {
return;
}
$pagePhone = (string) get_field('page_contact_phone');
$globalPhone = (string) get_field('global_support_phone', 'option');
$effectivePhone = '';
$source = 'none';
if ($pagePhone !== '') {
$effectivePhone = $pagePhone;
$source = 'page';
} elseif ($globalPhone !== '') {
$effectivePhone = $globalPhone;
$source = 'option';
}
if ($effectivePhone === '') {
error_log('[cta-fallback] source=none page=' . get_the_ID());
return;
}
$telHref = preg_replace('/[^0-9+]/', '', $effectivePhone);
echo '<section class="contact-cta">';
echo '<a class="phone-link" href="tel:' . esc_attr((string) $telHref) . '">' . esc_html($effectivePhone) . '</a>';
echo '</section>';
error_log('[cta-fallback] source=' . $source . ' page=' . get_the_ID());
});
wp eval 'update_field("global_support_phone", "+65 6700 1234", "option"); echo get_field("global_support_phone", "option") . PHP_EOL;'
wp eval '$id=1360; update_field("page_contact_phone", "", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!=="" ? $page : $global) . PHP_EOL;'
wp eval '$id=1360; update_field("page_contact_phone", "+65 6999 0000", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!=="" ? $page : $global) . PHP_EOL;'
+65 6700 1234
+65 6700 1234
+65 6999 0000
This fallback pattern uses option-context values from an options page workflow, which is commonly implemented with ACF Pro.
Use Case 2 — Hide empty social link cluster from Repeater rows
A content team can add optional social links in a footer component. Old posts often have empty rows or partially filled rows. You want to render only valid links and hide the entire social block when nothing usable remains.
- Read social rows from Repeater field.
- Normalize rows to name/url pairs.
- Filter rows where URL is empty.
- Render list only when filtered rows remain.
- Log hidden state for editorial follow-up.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_social_links_footer',
'title' => 'Footer Social Links',
'fields' => [
[
'key' => 'field_social_links',
'label' => 'Social Links',
'name' => 'social_links',
'type' => 'repeater',
'button_label' => 'Add Social Link',
'sub_fields' => [
[
'key' => 'field_social_platform',
'label' => 'Platform',
'name' => 'social_platform',
'type' => 'text',
],
[
'key' => 'field_social_url',
'label' => 'URL',
'name' => 'social_url',
'type' => 'url',
],
],
],
],
'location' => [[[
'param' => 'post_type',
'operator' => '==',
'value' => 'post',
]]],
]);
});
add_action('template_redirect', function (): void {
if (!is_single()) {
return;
}
$rows = get_field('social_links');
$rows = is_array($rows) ? $rows : [];
$validRows = array_values(array_filter($rows, static function (array $row): bool {
return !empty($row['social_url']);
}));
if (count($validRows) === 0) {
error_log('[social-guard] hidden_empty_cluster post=' . get_the_ID());
return;
}
echo '<ul class="social-links">';
foreach ($validRows as $row) {
$platform = (string) ($row['social_platform'] ?? 'Social');
$url = (string) $row['social_url'];
echo '<li><a href="' . esc_url($url) . '" rel="noopener noreferrer">' . esc_html($platform) . '</a></li>';
}
echo '</ul>';
});
wp post create --post_title="Fallback Social Drill" --post_status=publish --post_type=post
wp eval '$id=(int)get_page_by_title("Fallback Social Drill", OBJECT, "post")->ID; update_field("social_links", [["social_platform"=>"LinkedIn","social_url"=>"https://linkedin.com/company/clinic"],["social_platform"=>"Instagram","social_url"=>""],["social_platform"=>"YouTube","social_url"=>"https://youtube.com/@clinic"]], $id); $rows=get_field("social_links",$id); $valid=array_values(array_filter((array)$rows, fn($r)=>!empty($r["social_url"]))); echo count($valid) . PHP_EOL;'
Success: Created post 1361.
2
Filtering before rendering is cleaner than rendering and hiding empty rows with CSS.
Use Case 3 — Edge case: Flexible layout exists but inner required fields are missing
A page has a hero_section layout row, but migration left hero_title empty and CTA URL missing. Fragile templates emit broken markup. Robust templates validate inner fields and either fallback or skip safely.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
if (!have_rows('landing_sections')) {
return;
}
while (have_rows('landing_sections')) {
the_row();
if (get_row_layout() === 'hero_section') {
echo '<h2>' . esc_html((string) get_sub_field('hero_title')) . '</h2>';
echo '<a href="' . esc_url((string) get_sub_field('hero_cta_url')) . '">'
. esc_html((string) get_sub_field('hero_cta_label')) . '</a>';
}
}
});
✅ Robust Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
if (!is_page() || !have_rows('landing_sections')) {
return;
}
while (have_rows('landing_sections')) {
the_row();
$layout = (string) get_row_layout();
if ($layout !== 'hero_section') {
continue;
}
$title = (string) get_sub_field('hero_title');
$ctaLabel = (string) get_sub_field('hero_cta_label');
$ctaUrl = (string) get_sub_field('hero_cta_url');
$globalCtaLabel = (string) get_field('global_cta_label', 'option');
$globalCtaUrl = (string) get_field('global_cta_url', 'option');
if ($title === '') {
$title = 'Talk to our specialists today';
}
if ($ctaLabel === '' || $ctaUrl === '') {
$ctaLabel = $globalCtaLabel;
$ctaUrl = $globalCtaUrl;
}
echo '<section class="hero">';
echo '<h2>' . esc_html($title) . '</h2>';
if ($ctaLabel !== '' && $ctaUrl !== '') {
echo '<a class="hero-cta" href="' . esc_url($ctaUrl) . '">' . esc_html($ctaLabel) . '</a>';
}
echo '</section>';
error_log('[flex-fallback] page=' . get_the_ID() . ' title_fallback=' . (int) ($title === 'Talk to our specialists today'));
}
});
wp eval 'update_field("global_cta_label", "Book Consultation", "option"); update_field("global_cta_url", "/book", "option");'
wp eval '$id=1360; update_field("landing_sections", [["acf_fc_layout"=>"hero_section","hero_title"=>"","hero_cta_label"=>"","hero_cta_url"=>""]], $id); $rows=get_field("landing_sections",$id); echo $rows[0]["acf_fc_layout"] . "|" . ($rows[0]["hero_title"] === "" ? "empty-title" : "has-title") . PHP_EOL;'
wp eval '$rows=get_field("landing_sections",1360); $title=$rows[0]["hero_title"] ?? ""; $label=$rows[0]["hero_cta_label"] ?? ""; $url=$rows[0]["hero_cta_url"] ?? ""; if($title===""){$title="Talk to our specialists today";} if($label===""||$url===""){$label=get_field("global_cta_label","option"); $url=get_field("global_cta_url","option");} echo $title . "|" . $label . "|" . $url . PHP_EOL;'
hero_section|empty-title
Talk to our specialists today|Book Consultation|/book
A layout row existing does not mean it is render-ready. Validate required inner fields before output.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Rendering wrappers before value checks | Markup emitted without usable content | Empty sections and layout gaps | Guard entire block with effective-value checks |
| Hardcoding fallback text in multiple files | No single fallback source of truth | Inconsistent copy and hard refactors | Store global defaults in option fields |
| Treating any row existence as complete data | Missing subfields ignored | Broken CTA links, blank headings | Validate subfield completeness per layout |
| No fallback precedence policy | Developers improvise per template | Conflicting behavior across pages | Define page → option → hide precedence explicitly |
| Missing CLI checks for empty-state paths | QA covers only happy path | Edge cases leak to production | Add wp eval fallback-path tests per component |
| Silent fallback usage with no logging | Content issues remain invisible | Editorial quality declines over time | Log source and fallback hits for monitoring |
Deep Dive: Why Empty-State Bugs Survive Launch Checklists
Most manual QA uses fully populated demo content, so empty-state logic remains untested. Real production content includes drafts, migrated posts, and partial updates where required fields are temporarily missing. Bugs surface only on specific pages and appear intermittent. Without a formal fallback policy, different components fail in different ways. The fix is to test fallback paths explicitly with CLI and keep logs that reveal how often defaults are being used.
wp eval '$id=1360; $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo "source=" . ($page!==""?"page":($global!==""?"option":"none")) . PHP_EOL;'
Best Practices
- Define fallback precedence per component and document it near code.
- Hide optional blocks when no effective value exists; do not render empty wrappers.
- Use option-level defaults for truly global values (phone, default CTA, legal text).
- Validate inner fields for Flexible layouts before rendering layout markup.
- Log fallback source (
page,option,none) for production observability. - Add CLI checks for both populated and empty states to your release checklist.
- Keep fallback copy and links centrally managed to avoid drift.
Hands-On Practice
Exercise 1: Implement page → option phone fallback
Create wp-content/themes/clinic-pro/partials/contact-cta-fallback.php and run:
wp eval 'update_field("global_support_phone", "+65 6700 1234", "option");'
wp eval '$id=1360; update_field("page_contact_phone", "", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!==""?$page:$global) . PHP_EOL;'
After completing this exercise, output should be:
+65 6700 1234
Exercise 2: Verify page override wins over global fallback
Run:
wp eval '$id=1360; update_field("page_contact_phone", "+65 6999 0000", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!==""?$page:$global) . PHP_EOL;'
After completing this exercise, output should be:
+65 6999 0000
Exercise 3: Seed social rows and filter empty links
Run:
wp eval '$id=1361; $rows=[["social_platform"=>"LinkedIn","social_url"=>"https://linkedin.com/company/clinic"],["social_platform"=>"Instagram","social_url"=>""],["social_platform"=>"YouTube","social_url"=>"https://youtube.com/@clinic"]]; update_field("social_links",$rows,$id); $saved=get_field("social_links",$id); $valid=array_values(array_filter((array)$saved, fn($r)=>!empty($r["social_url"]))); echo count($valid) . PHP_EOL;'
After completing this exercise, output should be:
2
Exercise 4: Simulate empty Flexible hero internals and resolve fallback
Run:
wp eval 'update_field("global_cta_label", "Book Consultation", "option"); update_field("global_cta_url", "/book", "option");'
wp eval '$id=1360; update_field("landing_sections", [["acf_fc_layout"=>"hero_section","hero_title"=>"","hero_cta_label"=>"","hero_cta_url"=>""]], $id);'
wp eval '$rows=get_field("landing_sections",1360); $title=$rows[0]["hero_title"] ?? ""; $label=$rows[0]["hero_cta_label"] ?? ""; $url=$rows[0]["hero_cta_url"] ?? ""; if($title===""){$title="Talk to our specialists today";} if($label===""||$url===""){$label=get_field("global_cta_label","option"); $url=get_field("global_cta_url","option");} echo $title . "|" . $label . "|" . $url . PHP_EOL;'
After completing this exercise, output should be:
Talk to our specialists today|Book Consultation|/book
Exercise 5: Add fallback-source audit check
Run:
wp eval '$id=1360; $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo "source=" . ($page!==""?"page":($global!==""?"option":"none")) . PHP_EOL;'
After completing this exercise, expected output pattern:
source=page
or
source=option
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval 'echo get_field("global_support_phone", "option") . PHP_EOL;' | Read global fallback phone | +65 6700 1234 |
wp eval '$id=1360; echo (string)get_field("page_contact_phone",$id) . PHP_EOL;' | Read page-specific override | +65 6999 0000 |
wp eval '$id=1360; $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!==""?$page:$global) . PHP_EOL;' | Resolve effective fallback value | +65 6999 0000 |
wp eval '$id=1361; $rows=get_field("social_links",$id); $valid=array_values(array_filter((array)$rows, fn($r)=>!empty($r["social_url"]))); echo count($valid) . PHP_EOL;' | Count valid social links after filtering empties | 2 |
wp eval '$rows=get_field("landing_sections",1360); echo $rows[0]["acf_fc_layout"] . PHP_EOL;' | Verify first Flexible layout slug | hero_section |
wp eval '$rows=get_field("landing_sections",1360); print_r($rows[0]);' | Inspect inner fields for fallback checks | Array ( [hero_title] => ... ) |
wp post meta get 1360 page_contact_phone | Inspect raw per-page value for precedence audits | +65 6999 0000 |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro availability for options/flexible workflows |
What's Next
- Continue to ACF Blocks Core Concepts.
- Return to Module 3 Overview to keep rendering standards consistent.
- Related lesson: Reading Field Values in Templates.
- Related lesson: Load Value, Update Value, and Format Value Filters.
Revisit this lesson whenever your team reports "it works on one page but not another" for ACF-driven components; that symptom often indicates missing fallback or empty-state policy.