DEV Community

Christof Karisch
Christof Karisch

Posted on

Taming a Third-Party React App Inside WordPress — Without Touching Its Code

Sometimes the most interesting frontend work isn't building an app — it's bending one you don't control.

For the Austrian animal shelter Arche Noah (Aktiver Tierschutz Austria), the pet adoption listings are powered by a commercial WordPress plugin that renders a React app on the page. The shelter manages its animals in an external SaaS ("Pet-Manager"), and the plugin pulls that data and renders searchable lists, filters, and detail views.

The catch: the design didn't match the site at all, several fields were wrong for the audience (raw birth dates instead of age, cat genders in the dog filter), and important UI was missing (a phone CTA on every animal). And of course:

  • We can't fork the plugin — every update would wipe our changes.
  • We can't patch the React bundle — it's minified vendor code.
  • The markup doesn't exist at page load — React renders it client-side, and re-renders it constantly.

So we built an augmentation layer: a single custom-code block (CSS + vanilla JS with MutationObserver) that reshapes the app from the outside. You can see the result live on the dog adoption page and the cat adoption page.

This post is about the three rules we learned — including the React reconciliation bug that only showed up when users clicked "next animal".

Rule 1: Never move or remove React-managed DOM nodes

This is the big one, and we learned it the hard way.

The detail view shows a "Merkmale" (characteristics) table row that we wanted to display as its own "Besonderheiten" (special traits) card next to the "Eigenschaften" (properties) card. Our first version did the obvious thing: grab the .Eigenschaften node, move it into a new row, delete the characteristics <tr>.

It worked perfectly. On first load.

But the app has "previous animal / next animal" navigation. On those transitions, React doesn't rebuild the DOM from scratch — it reconciles: it diffs its virtual DOM against what it believes is in the real DOM and applies minimal patches. If you've moved or deleted nodes React thinks it owns, its bookkeeping is now wrong. In our case, React silently rendered a broken subtree: the characteristics row stopped being processed, and even an unrelated transformation (birth date → age) stopped firing.

No error, no warning. Just a subtly wrong UI, one click away from the state everyone tests.

The fix defines the whole approach:

  • Hide, don't remove. React doesn't care about display: none.
  • Append foreign nodes, don't reparent React's nodes. Elements you create and append survive React's re-renders — React ignores children it didn't render, as long as you don't disturb its own.
  • On every render, only update text/content. Never restructure.
function processDetail() {
  document.querySelectorAll('.petfinder .tm-pet:not(.card)').forEach(function (pet) {
    var eig = pet.querySelector('.Eigenschaften');
    if (!eig) return;

    // Read the current value from React's table (may be absent)
    var merkmaleValue = null;
    pet.querySelectorAll('.Allgemein .table tr').forEach(function (tr) {
      var first = tr.querySelector('td:first-child');
      if (first && first.textContent.trim().replace(':', '') === 'Merkmale') {
        var v = tr.querySelector('td:last-child');
        merkmaleValue = v ? v.textContent.trim() : '';
        // Hide only — this <tr> belongs to React
        tr.style.display = 'none';
        tr.setAttribute('data-kwm-mhidden', '');
      }
    });

    var show = (merkmaleValue && merkmaleValue !== '-')
      ? merkmaleValue
      : 'Keine Besonderheiten bekannt';

    // Our OWN node, appended into React's row — survives re-renders
    var eigRow = eig.closest('.row');
    if (!eigRow) return;
    var box = eigRow.querySelector('.kwm-besond');
    if (!box) {
      box = document.createElement('div');
      box.className = 'Besonderheiten card col-12 col-md-6 kwm-besond';
      eigRow.appendChild(box);
    }
    var html = '<div class="h3 mt-4">Besonderheiten</div><p>' + show + '</p>';
    // Write only on actual change — prevents observer feedback loops
    if (box.innerHTML !== html) box.innerHTML = html;
  });
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: Make every MutationObserver pass idempotent

All transformations run inside MutationObserver callbacks watching the app's root container:

function start() {
  var pf = document.getElementById('petfinder');
  if (!pf) return;
  processDetail();
  new MutationObserver(processDetail).observe(pf, { childList: true, subtree: true });
}

document.readyState === 'loading'
  ? document.addEventListener('DOMContentLoaded', start)
  : start();
Enter fullscreen mode Exit fullscreen mode

There's an obvious trap here: your callback mutates the DOM, which fires the observer, which runs the callback, which mutates the DOM… To stay out of that loop, every pass must be idempotent — running it twice must be a no-op the second time. We use three patterns, depending on how React treats the node:

Pattern A — a data- guard, for nodes React creates once and never recycles (list cards):

if (card.dataset.kwmDone) return;
// ...transform...
card.dataset.kwmDone = '1';
Enter fullscreen mode Exit fullscreen mode

Pattern B — a self-consuming condition, for nodes React recycles. This one is subtle: on "next animal", React reuses the existing <tr> elements and just resets their text — so a data- guard on the element would wrongly skip the fresh content. Instead, the transformation itself removes its own trigger condition:

// Fires only while the label is the untransformed "Geburtsdatum".
// After we rename it to "Alter", the condition no longer matches —
// until React's next render resets it. Idempotent, no guard needed.
document.querySelectorAll('.petfinder .tm-pet:not(.card) .table tr').forEach(function (tr) {
  var first = tr.querySelector('td:first-child');
  if (!first || first.textContent.trim() !== 'Geburtsdatum') return;
  var valueCell = tr.querySelector('td:last-child');
  var age = valueCell ? calcAge(valueCell.textContent) : null;
  if (age) {
    first.textContent = 'Alter';
    valueCell.textContent = age;
  } else {
    tr.style.display = 'none';
  }
});
Enter fullscreen mode Exit fullscreen mode

Pattern C — compare before writing, for content you keep in sync on every render (the box.innerHTML !== html check in Rule 1). No change, no mutation, no observer echo.

Rule 3: Let CSS do everything it can

The less JavaScript touches the DOM, the fewer reconciliation and observer problems you have. Two CSS tricks earned their keep:

Fixing zebra stripes after hiding a row. Hiding a <tr> with display: none doesn't remove it from :nth-child() counting — so table-striped flips parity from that row on, and your visible rows suddenly alternate wrong. Since we mark the hidden row with an attribute, a sibling combinator can flip the parity back for everything after it:

.table-striped > tbody > tr:nth-child(odd) { background: #eef3e5; }

/* Everything after the hidden row: invert the striping */
.table-striped > tbody > tr[data-kwm-mhidden] ~ tr:nth-child(odd) {
  background: transparent;
}
.table-striped > tbody > tr[data-kwm-mhidden] ~ tr:nth-child(even) {
  background: #eef3e5;
}
Enter fullscreen mode Exit fullscreen mode

Layout with :has() instead of moving nodes. Our appended "Besonderheiten" box lands as a third column in a Bootstrap row that React already populated. Instead of reordering React's columns (forbidden — see Rule 1), we force the other columns full-width, so our box and its partner wrap onto their own line together:

.tm-pet:not(.card) .row:has(> .kwm-besond) > [class*="col-"]:not(.Eigenschaften):not(.kwm-besond) {
  flex: 0 0 100% !important;
  max-width: 100% !important;
}
Enter fullscreen mode Exit fullscreen mode

One more that surprised us: Bootstrap 5's table-striped colors cells via an inset box-shadow and CSS custom properties, not background. If your override isn't sticking:

td {
  box-shadow: none !important;
  --bs-table-accent-bg: transparent !important;
}
Enter fullscreen mode Exit fullscreen mode

Bonus: context-aware behavior without an API

The plugin renders the same app everywhere; it doesn't know "this is the dogs page". We encode that context in wrapper classes (.kwm-filter.dogs, .kwm-slider.cats) around the shortcode and read them from the augmentation layer — e.g. to restrict the gender filter to Rüde/Hündin on the dog page and Kater/Kätzin on the cat page, or to route card clicks to the right list page:

var slider = card.closest('.kwm-slider');
if (slider) {
  if (slider.classList.contains('cats')) base = '/vergabekatzen/';
  else if (slider.classList.contains('dogs')) base = '/vergabehunde/';
  else base = window.location.pathname;
}
window.location.href = base + '?kwm_pf_id=' + id + '&kwm_pf_v=details';
Enter fullscreen mode Exit fullscreen mode

Cheap, declarative, and it survives plugin updates.

Takeaways

  1. Treat React-managed DOM as read-only structure. Hide with CSS, append your own nodes, update text — never move or delete what React rendered. The bugs you cause otherwise don't appear on first load; they appear on the second interaction.
  2. Design every observer pass to be idempotent, and pick the right guard: data- flags for stable nodes, self-consuming conditions for recycled nodes, compare-before-write for synced content.
  3. Prefer CSS over JS for layout and visibility — :has(), sibling combinators, and attribute hooks set by your JS layer cover more than you'd expect.
  4. Keep it all in one place. Everything lives in a single custom-code block, version-controlled in git — the plugin stays untouched and updateable.

Is this fragile? Somewhat — it's coupled to the vendor's class names, and a major plugin redesign would mean rework. But every transformation fails soft (a missing selector just means an untransformed field, never a crash), and compared to forking a commercial plugin, it's the far more maintainable trade-off.

And the real win isn't technical: the shelter — one of Austria's largest, with room for 163 dogs and 204 cats — gets an adoption UI that looks like their site and speaks to adopters, on aktivertierschutz.at. If you're in Austria and thinking about adopting: the dogs are over here. 🐾


This project was implemented by Sovaro Consulting — we help organizations get the most out of WordPress, even when third-party plugins fight back.

Top comments (0)