Skip to content

Instantly share code, notes, and snippets.

@crucialfelix
Last active May 13, 2024 14:17
Show Gist options
  • Save crucialfelix/bc014a03b0a00939452eb943d1180c62 to your computer and use it in GitHub Desktop.
Save crucialfelix/bc014a03b0a00939452eb943d1180c62 to your computer and use it in GitHub Desktop.
Django React Portal
{% load react_tags %}
<html>
<div>
{# the outer div is replaced by PortalLoader installing a react portal into it. the inner content is fallback or SEO content #}
{% react_portal NavBarSearch %}
<form action="{% url 'quick_search' %}" method="get" class="no-js-content header__search-form m-0"><input type="search" class="header__search m-0" size="12" name="search" id="search" placeholder="Enter Web ID or Street" /></form>
{% endreact_portal %}
</div>
{# base_site renders the webpack bundle PortalLoader #}
<div id="portal-loader"/>{% render_bundle 'PortalLoader' %}
</body>
</html>
// portals/NavBarSearch.tsx
// This component is the content of a portal
import React from "react";
import SearchSuggest from "/react/components/search/SearchSuggest";
export default function NavBarSearch() {
return <SearchSuggest />;
}
// This PortalLoader is a bundle
import React, { lazy, Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { createRoot } from "react-dom/client";
// Include these in the bundle since they are on every page
import NewsLetterSubscribe from "./portals/NewsLetterSubscribe";
import UserMenu from "./portals/UserMenu";
import ErrorBoundaryComponent from "./utils/ErrorBoundaryComponent";
const portals = {
NewsLetterSubscribe, // not lazy
UserMenu, // not lazy
NavBarSearch: lazy(() => import("./portals/NavBarSearch")),
AdminReact: lazy(() => import("./portals/AdminReact")),
AffiliatesPage: lazy(() => import("./portals/AffiliatesPage")),
// etc. no limit
};
type Portal = {
name: string;
component: React.LazyExoticComponent<React.ComponentType<any>>;
container: Element;
props: Record<string, string | number | null>;
};
type Portals = Record<string, Portal>;
/**
* PortalLoader is a component that will lazy load components and render them as a portal
* for each element with the attribute data-react-portal
*
* It does this when this is first mounted on the page.
*/
function PortalLoader() {
// on mount detect any data-react-portal,
// lazy load those components
// and render them in portals
const [components, setComponents] = useState<Portals>({});
useEffect(() => {
document.querySelectorAll("[data-react-portal]").forEach((el) => {
const name = el.getAttribute("data-react-portal");
const props = el.getAttribute("data-props");
const page = portals[name];
if (!page) {
if (process.env.NODE_ENV === "development") {
throw Error(`portal not registered: ${name}`);
} else {
console.error("portal not registered", name, portals);
return;
}
}
setComponents((prev) => ({
...prev,
[name]: {
name,
component: page,
container: el,
props: props ? JSON.parse(props) : {},
},
}));
});
}, []);
return (
<div id="portal-loader">
{Object.values(components).map((portal) =>
createPortal(
<ErrorBoundaryComponent silent={true}>
<Suspense fallback={null} key={portal.name}>
<portal.component {...portal.props} />
</Suspense>
</ErrorBoundaryComponent>,
portal.container,
),
)}
</div>
);
}
const container = document.getElementById("portal-loader");
// Probably due to the hot loader this is called multiple times
const didMount = !!container.getAttribute("data-did-mount");
if (!didMount) {
const root = createRoot(container);
root.render(<PortalLoader />);
container.setAttribute("data-did-mount", "true");
}
{# Django templatetag template #}
<div data-react-portal="{{portal_name}}" class="{{class_name}}" data-props="{{props}}">
{{ body }}
</div>
import json
from django import template
from globalapp.utils.template_utils import render_template
register = template.Library()
defaults = {"class_name": "", "width": "100%", "height": "auto"}
@register.tag(name="react_portal")
def react_portal(parser, token):
"""
react_portal : Place a React component onto the page, mounted as a React Portal.
Portals should be in frontend/react/portals
and should be added to PortalLoader.tsx
Usage:
{% react_portal AffiliatesPage %}
<div class="no-js-content">
<h2>fallback content</h2>
</div>
{% endreact_portal %}
Any extra supplied args are passed to the react component as props:
{% react_portal AgentsListings agentId=agentId %}
<div class="no-js-content">
<h2>fallback content</h2>
</div>
{% endreact_portal %}
div with ".no-js-content" will only be displayed if the browser does not have
javascript enabled.
"""
nodelist = parser.parse("endreact_portal")
parser.delete_first_token()
bits = token.split_contents()
varz = bits[1:]
if len(varz) == 0:
raise Exception("Portal name not supplied for react_portal tag")
portal_name = varz[0]
if '"' in portal_name:
raise Exception(
f"react_portal: Portal names should be supplied without quotes. Got: {portal_name}"
)
more_context = {"portal_name": portal_name}
# Read any additional key=value argument to pass through as React component props
for key_eq_value in varz[1:]:
if "=" not in key_eq_value:
raise Exception(
f"react_portal invalid prop attribute: {key_eq_value} expected key=value"
)
k, v = key_eq_value.split("=")
more_context[k] = template.Variable(v)
return ReactPortal(nodelist, more_context)
class ReactPortal(template.Node):
def __init__(self, nodelist, more_context):
self.nodelist = nodelist
self.more_context = more_context
self.defaults = defaults.copy()
def render(self, context):
# make a child context
dd = template.Context()
dd.update(self.defaults)
for k, v in self.more_context.items():
dd[k] = v if isinstance(v, str) else v.resolve(context)
# pass in any extra template tag args to react props
ignore_args = [
"width",
"height",
"class_name",
"portal_name",
"True",
"False",
"None",
]
props = {
key: value for key, value in dd.flatten().items() if key not in ignore_args
}
dd["props"] = json.dumps(props)
dd["body"] = self.nodelist.render(context)
return render_template("templatetags/react_portal.html", dd.flatten())

React Portals

To place a react component onto any Django page:

  1. Create a top level component in frontend/portals
  2. Add that to portals in frontend/react/PortalLoader.tsx
  3. Use this Django tag to place a div on the page:
{% react_portal NestYachts %}

Any extra supplied args are passed to the react component as props:

        {% react_portal AgentsListings agentId=agentId %}
            <div class="no-js-content">
                <h2>fallback content</h2>
            </div>
        {% endreact_portal %}

div with ".no-js-content" will only be displayed if the browser does not have javascript enabled. It is important for SEO to put content here.

ThePortalLoader is a webpack bundle which is placed once on the base_site.html. When the page is first loaded it will search for all elements with data-react-portal, lazy load their bundles and activate them.

You can't dynamically add a portal using this system—it is intended for enhancing the Django pages.

You can dynmamically add a portal using standard react from any other part of your application.

// Include PortalLoader as it's own bundle
module.exports = {
context: __dirname,
mode: production ? "production" : "development",
target: "web",
entry: {
// Slim top level public React app that will lazy load
PortalLoader: ["./react/PortalLoader.tsx"],
}
// rest of your config
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment