Hello.

React

Context + Hooks

(and also cool headless SilverStripe things)

Webflow at a glance...

  • + visual interface
  • + light, mostly read-only database
  • + staging environment

fun, no code

  • - plans too restrictive or expensive
  • - seriously flawed change sets
  • - no server code

reasons for moving away

SilverStripe vs. WordPress

  • + nice user management + permissions
  • + useful change sets
  • + data objects + model admins

out the box...

  • + top-notch security + dependability
  • + first-party support where we need it
  • + we're familiar with the modules

Good Business Reasons™

Our setup

Headless

(even by the recently-expanded definition)

NextJS front-end

BitBucket pipelines

(for linting, automated tests, and deployment)

Hosted in AWS

(slightly over-engineered though)

NextJS at a glance...

Great starting point

yarn init -y
yarn add next react react-dom
{
	"name": "@indiefin/website",
	"version": "1.0.0",
	"private": true,
	"scripts": {
		"dev": "next",
		"build": "next build",
		"start": "next start",
		"docker:start": "next start -p 8080 -H 0.0.0.0",
		...
	},
	"dependencies": {
		...
	},
	"devDependencies": {
		...
	},
	"resolutions": {
		...
	},
	"workspaces": [
		...
	]
}

// package.json
const HomePage = () => {
	return <div>A new website!</div>
}

export default HomePage

// pages/index.js

Things we're doing in SilverStripe

Menus

(heyday/silverstripe-menumanager)

Blog

(silverstripe/blog)

Markdown

(fork of silverstripers/markdown)

Reusable Content

(that's just like...a data object, man)

JSON API Controllers

(because no API module is good enough)

probably because we don't understand how they work

import axios from 'axios'
import https from 'https'

const requestCms = axios.create({
	baseURL: process.env.HOST_CMS,
	headers: { Accept: 'application/json;charset=UTF-8' },
	httpsAgent: process.env.HOST_CMS.startsWith('https')
		? new https.Agent({ rejectUnauthorized: false })
		: undefined,
})

Reusable Content Shenanigans

"Let's design us a component!"

<Product
	title="Funeral Cover"
	description="Funeral Cover that doubles after 2 years at no extra cost."
/>
const Product = ({ title, description }) => {
	return (
		<div className="product-card">
			<div className="product-card-title">{title}</div>
			<div className="product-card-description">{description}</div>
		</div>
	)
}

export { Product }

"Wait, how do we make it dynamic?"

import { useEffect, useState } from 'react'

const Product = ({ title, description }) => {
	const [dynamicTitle, setDynamicTitle] = useState(title)
	const [dyanmicDescription, setDynamicDescription] = useState(description)

	useEffect(() => {
		fetch(`http://localhost:3000/api/reusable?name=${title}`)
			.then((response) => response.json())
			.then(({ content }) => setDynamicTitle(content))
	}, [title])

	useEffect(() => {
		fetch(`http://localhost:3000/api/reusable?name=${description}`)
			.then((response) => response.json())
			.then(({ content }) => setDynamicDescription(content))
	}, [description])

	return (
		<div>
			<div>{dynamicTitle}</div>
			<div>{dyanmicDescription}</div>
		</div>
	)
}

export { Product }

"That's not very scalable..."

return (
	<ProductCard>
		<ProductCardTitle>
			<Reusable name={title} />
		</ProductCardTitle>
		<ProductCardDescription>
			<Reusable name={description} />
		</ProductCardDescription>
	</ProductCard>
);
import { useEffect, useState } from 'react'

const Reusable = ({ name }) => {
	const [content, setContent] = useState(null)

	useEffect(() => {
		fetch(`http://localhost:3000/api/reusable?name=${name}`)
			.then((response) => response.json())
			.then(({ content }) => setContent(content))
	}, [name])

	return content
}

export { Reusable }

"What if we want to turn it off and on again?"

import { createContext } from 'react'

const ReusableContext = createContext(undefined)

const DefaultOffReusableContextProvider = ({ children }) => (
	<ReusableContext.Provider value={{ isReusable: false }}>
		{children}
	</ReusableContext.Provider>
)

const DefaultOnReusableContextProvider = ({ children }) => (
	<ReusableContext.Provider value={{ isReusable: true }}>
		{children}
	</ReusableContext.Provider>
)

export {
	ReusableContext,
	DefaultOffReusableContextProvider,
	DefaultOnReusableContextProvider,
}
import {
	DefaultOffReusableContextProvider,
	DefaultOnReusableContextProvider,
} from '../state'

import { Product, Reusable } from '../components'

const HomePage = () => {
	return (
		<DefaultOffReusableContextProvider>
			<Product
				title="Testing a new Product"
				description="...with a lovely description!"
			/>
			<DefaultOnReusableContextProvider>
				<Product
					title="funeral-cover-title"
					description="funeral-cover-description"
				/>
			</DefaultOnReusableContextProvider>
		</DefaultOffReusableContextProvider>
	)
}

export default HomePage
import { useContext } from 'react'
import { ReusableContext } from '../state'
import { Reusable } from '../components'

const Product = ({ title, description }) => {
	const { isReusable } = useContext(ReusableContext)

	return (
		<ProductCard>
			<ProductCardTitle>
				{isReusable ? <Reusable name={title} /> : title}
			</ProductCardTitle>
			<ProductCardDescription>
				{isReusable ? <Reusable name={description} /> : description}
			</ProductCardDescription>
		</ProductCard>
	)
}

export { Product }

"What if we want to turn it off and on

ON THE SAME COMPONENT?!"

🤔

const valueOf = (value) => {
	if (typeof value === 'function') {
		return value()
	}

	if (value.startsWith('reusable:')) {
		value = value.replace('reusable:', '')
		value = <Reusable name={value} />
	}

	return value
}
import { valueOf as 🐮 } from '../helpers'

const Product = ({ title, description }) => {
	return (
		<ProductCard>
			<ProductCardTitle>{🐮(title)}</ProductCardTitle>
			<ProductCardDescription>{🐮(description)}</ProductCardDescription>
		</ProductCard>
	)
}

export { Product }

"All those requests...you maniac!"

import { useEffect, useState } from 'react'

const queued = []

const BatchedReusable = ({ name }) => {
	const [content, setContent] = useState(null)
	queued.push({ name, setContent })
	return content
}

const Fetcher = () => {
	useEffect(() => {
		fetchReusableContent()
      
		return () => {
			// maybe cancel these on unmount...
		}
	}, [])

	return null
}

const fetchReusableContent = () => {
	const joined = queued.map((queued) => queued.name).join(',')
    
	fetch(`http://localhost:3000/api/reusable?names=${joined}`)
		.then((response) => response.json())
		.then((contents) => {
			for (let {name, setContent} of queued) {
				if (contents[name]) {
					setContent(contents[name])
				}
			}
		})
}

export {
	BatchedReusable,
	Fetcher,
}
import { Fetcher } from '../components'

const Layout = ({ children }) => {
	return (
		<div className="layout">
			{children}
			<Fetcher />
		</div>
	)
}

export { Layout }

Redux Lite™

  • - reducers + actions
  • - connect + higher-order components
  • - async actions

massive learning curve

  • - reducers + actions
  • - connect + higher-order components
  • - async actions

massive learning curve

  • - reducers + actions
  • - connect + higher-order components
  • - async actions
  • + reduce (heh!) the dependencies

massive learning curve

import { useEffect, useReducer } from 'react'

const initial = {
	posts: [],
	loading: false,
	viewing: undefined,
}

const reducer = (state, { type, payload }) => {
	if (type === 'load') return {
		...state,
		loading: true,
	}
  
	if (type === 'loaded') return {
		...state,
		loading: false,
		posts: payload,
	}
  
	if (type === 'view') return {
		...state,
		viewing: payload,
	}
  
	if (type === 'back') return {
		...state,
		viewing: undefined,
	}
  
	return state
}
import { useEffect, useReducer } from 'react'

// ...

const Blog = () => {
	const [{ posts, loading, viewing }, dispatch] = useReducer(reducer, initial);
  
	useEffect(() => {
		dispatch({ type: 'load' })
      
		fetch('https://localhost:3000/api/posts')
			.then((response) => response.json())
			.then(({ posts }) => dispatch({ type: 'loaded', payload: posts }))
	}, [])
  
	if (loading) {
		return 'Loading...'
	}
  
	if (viewing) {
		return <Post {...viewing} onBack={() => dispatch({ type: 'back' })} />
	}
  
	return <Posts posts={posts} onSelect={(post) => dispatch({ type: 'view', payload: post })} />
}

"Can we share and mix

reducers and initials?"

import { createContext } from 'react'
import { initial as articlesInitial, reducer as articlesReducer } from './articles'
import { initial as productsInitial, reducer as productsReducer } from './products'

export const initial = {
	...articlesInitial,
	...productsInitial,
}

export const reducer = (state, action) => {
	state = articlesReducer(state, action)
	state = productsReducer(state, action)
	return state
}

export const context = createContext(undefined)
import { context as AppContext, initial, reducer } from '../reducers'
import { Router } from '../screens'

const AppProvider = ({ children }) => {
	const [state, dispatch] = useReducer(reducer, initial)
    
	return (
		<AppContext.Provider value={[state, dispatch]}>
			{children}
		</AppContext.Provider>
	)
}

const App = () => (
	<AppProvider>
		<Router />
	</AppProvider>
)

export { App }
import React, { useEffect, useContext } from 'react'
import { context } from '../reducers'
import { load } from '../reducers/products'
import { Product } from '../components'

const ListProducts = ({ match }) => {
	const [{ products }, dispatch] = useContext(context)

	useEffect(() => {
		dispatch(load())
	}, [])

	return (
		<div>
			{products.loading && <div>loading products...</div>}
			{products.items.length ? (
				<ol>
					{products.items.map(product => (
						<Product key={product.id} {...product} />
					))}
				</ol>
			) : (
				<div>no products</div>
			)}
		</div>
	)
}

export { ListProducts }

questions?

Bonus bits

namespace Indie\Models;

use SilverStripe\Blog\Model\Blog;
use SilverStripe\ORM\HiddenClass;

// hide this page type when creating new pages...
class HiddenBlog extends Blog implements HiddenClass
{
    // hide the standard blog section page type...
    private static $hide_ancestor = Blog::class;
}
namespace Indie\Models;

use SilverStripe\Blog\Model\Blog;

class LearnSection extends Blog
{
    private static $table_name = 'IndieLearnSection';
    private static $allowed_children = [LearnPost::class];
    private static $description = 'Adds a learn to your website.';

    public function canCreate($member = null, $context = [])
    {
        // can't be a child of another page...
        if (isset($context['Parent'])) {
            return false;
        }

        // only one learn (blog) section at a time...
        return !LearnSection::get()->first();
    }
}

MDX problems

import React from 'react'
import MDX from '@mdx-js/runtime'

const components = {
	h1: props => <h1 className="larger-text" {...props} />,
}

const mdx = `
	# Welcome, customer!
`

const HomePage = () => (
	<MDX components={components}>
		{mdx}
	</MDX>
)

"Please don't use eval"

next-mdx-remote

export async function getStaticPaths() {
	const response = requestCms.get('/api/slugs/all/live')
	const paths = response.data

	const slugs = paths.data.map((url) => ({
		params: { slug: url.split('/') }
	}))

	return { paths: slugs, fallback: true }
}

export async function getStaticProps({ params }) {
	const { page } = await getPage({ ...params })
	const source = await renderToString(page.content, { components })

	return {
		props: {
			...data,
			source,
		},
		revalidate: 30,
	}
}

"Please don't build in the pipeline"

😓

export async function getServerSideProps(req) {
	const { page } = await getPage({ slug: req.query.slug })
	const source = await renderToString(page.content, { components })

	return {
		props: {
			...data,
			source,
		},
	}
}

React Context + Hooks (September 2020)

By Christopher Pitt

React Context + Hooks (September 2020)

  • 1,208