In the last three parts of this series, I wrote about how one can use the webpack’ed version of a ReactJS application as a template engine for spring boot, how routing and navigation can work on the server side and how to improve the first-load performance of this app.
There is one thing missing in this application: The server side rendering does not use any dynamic data at all yet. With this blog post, I want to change that: I want to show you how the server can pre-render data that the client would usually fetch dynamically.
Fetch data dynamically
In the commit 20264c…, I changed the list component to fetch data dynamically:
export function List(props: any) {
const [ list, setList ] = useState<any[]>([])
const [ newItem, setNewItem ] = useState('')
const fetchList = async () => {
const response = await window.fetch('/api/list')
const list = await response.json()
setList(list)
}
/* ... */
useEffect(()=>{fetchList()}, [])
return (
<div>
<h1>List</h1>
<ul>{list.map(i => <li>{i.content}</li>)}</ul>
<div>
<input type="text" value={newItem} onChange={e=>setNewItem(e.target.value)} />
<button onClick={addNewItem}>Add</button>
</div>
</div>
)
}
The call to useEffect
will call fetchList
once when the List
component was mounted. fetchList
then fetches the list items from the server and the component renders them in an <ul>
.
When adding a new item, addNewItem
posts the data to the server, and, upon completion, calls fetchList
again to update the list of items.
On the server side, there is a new ApiController.kt
which, for now, just keeps the list of items in an internal ArrayList
:
@Controller()
@RequestMapping("/api")
class ApiController {
/* ... */
@GetMapping("/list")
@ResponseBody
fun getList(): List<Item> { /* ... */ }
@PostMapping("/add")
@ResponseStatus(HttpStatus.ACCEPTED)
fun addItem(@RequestBody item: Item) { /* ... */ }
}
Now the app can already render the list of items from the server and add items dynamically. To make that work when one starts the app with npm start
too, I had to configure a proxy for react-scripts
in package.json
:
"proxy": "http://localhost:8080",
Adding Data to JavaScript on the Server
Now, the react application can render the list dynamically in the browser, but the server side will always render an empty list because the callback to useEffect
that loads the items will never run in react-dom-server
.
To enable rendering the list on the server (in commit 3f2037…), I first add a Java API to the script engine: On the server, I want to get the data by calling a Java method from JavaScript, not by fetching the data over the network. So, in Controller.kt
, I inject a ServerApi
and add it to the script engine:
@Controller
class HtmlController {
private val serverApi: ServerApi
/* ... */
@Autowired
constructor(serverApi: ServerApi) {
this.serverApi = serverApi
}
/* ... */
private fun initializeEngine(): GraalJSScriptEngine {
val engine = GraalJSScriptEngine.create(null,
Context.newBuilder("js")
.allowHostAccess(HostAccess.ALL)
.allowHostClassLookup({ s -> true }))
engine.put("api", serverApi)
engine.eval("window = { location: { hostname: 'localhost' }, api: api }")
/* ... */
return engine
}
}
The ServerApi.kt
gets the list to render from an ApiService
. It also converts it to JSON: Doing that makes it easier to pick up the data on the JavaScript side later.
@Service
class ServerApi @Autowired constructor(val apiService: ApiService) {
fun getList(): String {
return ObjectMapper().writeValueAsString(apiService.getList())
}
}
Now the ApiService.kt
keeps the list of items and provides the functionality of adding new items. I still use an array list in this simple example - in a real application, this would be some kind of persistent data store.
data class Item(val content: String, val id: UUID)
@Service
class ApiService {
private val items: MutableList<Item> = ArrayList()
fun getList(): List<Item> {
return items
}
fun addItem(content: String) {
items.add(Item(content, UUID.randomUUID()))
}
}
The ApiController.kt
now uses the ApiService
too, so the list data always goes through the ApiService
@GetMapping("/list")
@ResponseBody
fun getList(): List<Item> {
return apiService.getList()
}
@PostMapping("/add")
@ResponseStatus(HttpStatus.ACCEPTED)
fun addItem(@RequestBody item: NewItem) {
apiService.addItem(item.content)
}
Rendering the Data on the Server
Now that the data is available to the JavaScript code on the server side, I can render it in the list component:
import { onServer } from './onServer'
export function List(props: any) {
const initialList = onServer(serverApi => serverApi.getList(), [])
const [ list, setList ] = useState<any[]>(initialList)
/* ... */
return (
<div>
<h1>List</h1>
<ul>{list.map(i => <li key={i.id}>{i.content}</li>)}</ul>
<div>
<input type="text" value={newItem} onChange={e=>setNewItem(e.target.value)} />
<button onClick={addNewItem}>Add</button>
</div>
</div>
)
}
On the server, the list initializes its list
state with the result of serverApi.getList()
, in the browser, onServer
returns the default value of []
. And onServer
calls the callback with the server API that was added to window.api
by the Controller.kt
:
export function onServer<T>(callback: ServerCallback, defaultValue: T): T {
const anyWindow: any = window
if(anyWindow.isServer) {
return JSON.parse(callback(anyWindow.api))
}
return defaultValue
}
It also parses the result - remember, it was converted to JSON in the Kotlin code.
Do Not Load Data Again
Now this code pre-renders the list on the server and the HTTP request already contains the rendered list items. But when ReactJS hydrates the client, useEffect
runs and fetches the list from the server again. On the other hand, I cannot remove this useEffect
, because switching to the list could be a client-side navigation with no server involved. In this case, the app must fetch the data from the server.
I decided to change onServer
so that it also renders the data itself to the HTML output (in commit 36774b…):
export function onServer<T>(callback: ServerCallback, defaultValue: T, valueIdentifier: string): [ T, ReactElement? ] {
const anyWindow: any = window
if(anyWindow.isServer) {
const jsonValue = callback(anyWindow.api);
const sanitizedJson = jsonValue
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/</g, '<')
.replace(/>/g, '>')
const scriptContent = `
<script>
if(!window.serverData) { window.serverData = {}}
window.serverData['${valueIdentifier}'] = JSON.parse("${sanitizedJson}".replace(/</g, '<').replace(/>/g, '>'))
</script>
`
const initScript = <div dangerouslySetInnerHTML={ { __html: scriptContent} }></div>
return [ JSON.parse(jsonValue), initScript ]
}
return [ defaultValue, undefined ]
}
onServer
now returns the value and also a <script>
tag that puts the same value into window.serverData.[some identifier]
. The code also escapes some characters from the JSON string, especially <
and >
: Otherwise, a malicious user could try to put the string </script>
into the JSON value, which would immediately end the generated script tag and wreak havoc with the application. When reading the value, the code must then also un-escape <
and >
again.
Now the list component can render this generated script component (that will only exist when the list was loaded from the server, not when there was a dynamic navigation in the browser) and pick up the value inside the useEffect
callback:
import { onServer, serverData, } from './onServer'
export function List(props: any) {
const [ initialList, initScript ] = onServer(serverApi => serverApi.getList(), [], 'app.list')
const [ list, setList ] = useState<any[]>(initialList)
const [ newItem, setNewItem ] = useState('')
const fetchList = async () => {
var listData: any[] = serverData('app.list')
if(!listData) {
const response = await window.fetch('/api/list')
listData = await response.json()
}
setList(listData)
}
/* ... */
return (
<div>
<h1>List</h1>
<ul>{ list.map(i => <li key={i.id}>{i.content}</li>) }</ul>
<div>
<input type="text" value={newItem} onChange={e=>setNewItem(e.target.value)} />
<button onClick={addNewItem}>Add</button>
</div>
{ initScript }
</div>
)
}
The code only fetches the list from the server when serverData(...)
did not return any value (i.e. on a dynamic navigation). Otherwise it uses the data that was set inside the generated <script>
tag, which the list also renders in it’s output if it exists (see { initScript }
in the JSX code).
The serverData
function returns the data from the server (if it exists) and also removes it, so that all subsequent useEffect
do fetch the data from the server:
export function serverData(valueIdentifier: string): any {
const anyWindow: any = window
if(anyWindow.serverData) {
const value = anyWindow.serverData[valueIdentifier]
anyWindow.serverData[valueIdentifier] = undefined
return value
}
return undefined
}
To Recap…
In the last four blog posts, I created a ReactJS application with create-react-app
and used the webpack’ed build of this application as a template engine for Spring Boot (almost without modification). I am running the server side application on GraalVM to be able to use modern JavaScript features and also for performance reasons.
Navigation with react-router
works on the server side too, and since today, the server also uses dynamic data to render the react components.
Pre-rendering the application on the server side also improves the time to first render significantly, if we tweak loading the JavaScript and CSS a bit.
Read / watch all parts of “Spring and Isomorphic React” here: